1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'app_bar.dart';
6/// @docImport 'scaffold.dart';
7library;
8
9import 'dart:math' as math;
10import 'dart:ui' as ui;
11
12import 'package:flutter/foundation.dart' show clampDouble;
13import 'package:flutter/rendering.dart';
14import 'package:flutter/widgets.dart';
15
16import 'colors.dart';
17import 'constants.dart';
18import 'theme.dart';
19
20/// The collapsing effect while the space bar collapses from its full size.
21enum CollapseMode {
22 /// The background widget will scroll in a parallax fashion.
23 parallax,
24
25 /// The background widget pin in place until it reaches the min extent.
26 pin,
27
28 /// The background widget will act as normal with no collapsing effect.
29 none,
30}
31
32/// The stretching effect while the space bar stretches beyond its full size.
33enum StretchMode {
34 /// The background widget will expand to fill the extra space.
35 zoomBackground,
36
37 /// The background will blur using a [ui.ImageFilter.blur] effect.
38 blurBackground,
39
40 /// The title will fade away as the user over-scrolls.
41 fadeTitle,
42}
43
44/// The part of a Material Design [AppBar] that expands, collapses, and
45/// stretches.
46///
47/// {@youtube 560 315 https://www.youtube.com/watch?v=mSc7qFzxHDw}
48///
49/// Most commonly used in the [SliverAppBar.flexibleSpace] field, a flexible
50/// space bar expands and contracts as the app scrolls so that the [AppBar]
51/// reaches from the top of the app to the top of the scrolling contents of the
52/// app. When using [SliverAppBar.flexibleSpace], the [SliverAppBar.expandedHeight]
53/// must be large enough to accommodate the [SliverAppBar.flexibleSpace] widget.
54///
55/// Furthermore is included functionality for stretch behavior. When
56/// [SliverAppBar.stretch] is true, and your [ScrollPhysics] allow for
57/// overscroll, this space will stretch with the overscroll.
58///
59/// The widget that sizes the [AppBar] must wrap it in the widget returned by
60/// [FlexibleSpaceBar.createSettings], to convey sizing information down to the
61/// [FlexibleSpaceBar].
62///
63/// {@tool dartpad}
64/// This sample application demonstrates the different features of the
65/// [FlexibleSpaceBar] when used in a [SliverAppBar]. This app bar is configured
66/// to stretch into the overscroll space, and uses the
67/// [FlexibleSpaceBar.stretchModes] to apply `fadeTitle`, `blurBackground` and
68/// `zoomBackground`. The app bar also makes use of [CollapseMode.parallax] by
69/// default.
70///
71/// ** See code in examples/api/lib/material/flexible_space_bar/flexible_space_bar.0.dart **
72/// {@end-tool}
73///
74/// See also:
75///
76/// * [SliverAppBar], which implements the expanding and contracting.
77/// * [AppBar], which is used by [SliverAppBar].
78/// * <https://material.io/design/components/app-bars-top.html#behavior>
79class FlexibleSpaceBar extends StatefulWidget {
80 /// Creates a flexible space bar.
81 ///
82 /// Most commonly used in the [AppBar.flexibleSpace] field.
83 const FlexibleSpaceBar({
84 super.key,
85 this.title,
86 this.background,
87 this.centerTitle,
88 this.titlePadding,
89 this.collapseMode = CollapseMode.parallax,
90 this.stretchModes = const <StretchMode>[StretchMode.zoomBackground],
91 this.expandedTitleScale = 1.5,
92 }) : assert(expandedTitleScale >= 1);
93
94 /// The primary contents of the flexible space bar when expanded.
95 ///
96 /// Typically a [Text] widget.
97 final Widget? title;
98
99 /// Shown behind the [title] when expanded.
100 ///
101 /// Typically an [Image] widget with [Image.fit] set to [BoxFit.cover].
102 final Widget? background;
103
104 /// Whether the title should be centered.
105 ///
106 /// If the length of the title is greater than the available space, set
107 /// this property to false. This aligns the title to the start of the
108 /// flexible space bar and applies [titlePadding] to the title.
109 ///
110 /// By default this property is true if the current target platform
111 /// is [TargetPlatform.iOS] or [TargetPlatform.macOS], false otherwise.
112 final bool? centerTitle;
113
114 /// Collapse effect while scrolling.
115 ///
116 /// Defaults to [CollapseMode.parallax].
117 final CollapseMode collapseMode;
118
119 /// Stretch effect while over-scrolling.
120 ///
121 /// Defaults to include [StretchMode.zoomBackground].
122 final List<StretchMode> stretchModes;
123
124 /// Defines how far the [title] is inset from either the widget's
125 /// bottom-left or its center.
126 ///
127 /// Typically this property is used to adjust how far the title is
128 /// inset from the bottom-left and it is specified along with
129 /// [centerTitle] false.
130 ///
131 /// If [centerTitle] is true, then the title is centered within the
132 /// flexible space bar with a bottom padding of 16.0 pixels.
133 ///
134 /// If [centerTitle] is false and [FlexibleSpaceBarSettings.hasLeading] is true,
135 /// then the title is aligned to the start of the flexible space bar with the
136 /// [titlePadding] applied. If [titlePadding] is null, then defaults to start
137 /// padding of 72.0 pixels and bottom padding of 16.0 pixels.
138 final EdgeInsetsGeometry? titlePadding;
139
140 /// Defines how much the title is scaled when the FlexibleSpaceBar is expanded
141 /// due to the user scrolling downwards. The title is scaled uniformly on the
142 /// x and y axes while maintaining its bottom-left position (bottom-center if
143 /// [centerTitle] is true).
144 ///
145 /// Defaults to 1.5 and must be greater than 1.
146 final double expandedTitleScale;
147
148 /// Wraps a widget that contains an [AppBar] to convey sizing information down
149 /// to the [FlexibleSpaceBar].
150 ///
151 /// Used by [Scaffold] and [SliverAppBar].
152 ///
153 /// `toolbarOpacity` affects how transparent the text within the toolbar
154 /// appears. `minExtent` sets the minimum height of the resulting
155 /// [FlexibleSpaceBar] when fully collapsed. `maxExtent` sets the maximum
156 /// height of the resulting [FlexibleSpaceBar] when fully expanded.
157 /// `currentExtent` sets the scale of the [FlexibleSpaceBar.background] and
158 /// [FlexibleSpaceBar.title] widgets of [FlexibleSpaceBar] upon
159 /// initialization. `scrolledUnder` is true if the [FlexibleSpaceBar]
160 /// overlaps the app's primary scrollable, false if it does not, and null
161 /// if the caller has not determined as much.
162 /// See also:
163 ///
164 /// * [FlexibleSpaceBarSettings] which creates a settings object that can be
165 /// used to specify these settings to a [FlexibleSpaceBar].
166 static Widget createSettings({
167 double? toolbarOpacity,
168 double? minExtent,
169 double? maxExtent,
170 bool? isScrolledUnder,
171 bool? hasLeading,
172 required double currentExtent,
173 required Widget child,
174 }) {
175 return FlexibleSpaceBarSettings(
176 toolbarOpacity: toolbarOpacity ?? 1.0,
177 minExtent: minExtent ?? currentExtent,
178 maxExtent: maxExtent ?? currentExtent,
179 isScrolledUnder: isScrolledUnder,
180 hasLeading: hasLeading,
181 currentExtent: currentExtent,
182 child: child,
183 );
184 }
185
186 @override
187 State<FlexibleSpaceBar> createState() => _FlexibleSpaceBarState();
188}
189
190class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
191 bool _getEffectiveCenterTitle(ThemeData theme) {
192 return widget.centerTitle ??
193 switch (theme.platform) {
194 TargetPlatform.android ||
195 TargetPlatform.fuchsia ||
196 TargetPlatform.linux ||
197 TargetPlatform.windows => false,
198 TargetPlatform.iOS || TargetPlatform.macOS => true,
199 };
200 }
201
202 Alignment _getTitleAlignment(bool effectiveCenterTitle) {
203 if (effectiveCenterTitle) {
204 return Alignment.bottomCenter;
205 }
206 return switch (Directionality.of(context)) {
207 TextDirection.rtl => Alignment.bottomRight,
208 TextDirection.ltr => Alignment.bottomLeft,
209 };
210 }
211
212 double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) {
213 switch (widget.collapseMode) {
214 case CollapseMode.pin:
215 return -(settings.maxExtent - settings.currentExtent);
216 case CollapseMode.none:
217 return 0.0;
218 case CollapseMode.parallax:
219 final double deltaExtent = settings.maxExtent - settings.minExtent;
220 return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
221 }
222 }
223
224 @override
225 Widget build(BuildContext context) {
226 return LayoutBuilder(
227 builder: (BuildContext context, BoxConstraints constraints) {
228 final FlexibleSpaceBarSettings settings = context
229 .dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
230
231 final List<Widget> children = <Widget>[];
232
233 final double deltaExtent = settings.maxExtent - settings.minExtent;
234
235 // 0.0 -> Expanded
236 // 1.0 -> Collapsed to toolbar
237 final double t = clampDouble(
238 1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent,
239 0.0,
240 1.0,
241 );
242
243 // background
244 if (widget.background != null) {
245 final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
246 const double fadeEnd = 1.0;
247 assert(fadeStart <= fadeEnd);
248 // If the min and max extent are the same, the app bar cannot collapse
249 // and the content should be visible, so opacity = 1.
250 final double opacity = settings.maxExtent == settings.minExtent
251 ? 1.0
252 : 1.0 - Interval(fadeStart, fadeEnd).transform(t);
253 double height = settings.maxExtent;
254
255 // StretchMode.zoomBackground
256 if (widget.stretchModes.contains(StretchMode.zoomBackground) &&
257 constraints.maxHeight > height) {
258 height = constraints.maxHeight;
259 }
260 final double topPadding = _getCollapsePadding(t, settings);
261 children.add(
262 Positioned(
263 top: topPadding,
264 left: 0.0,
265 right: 0.0,
266 height: height,
267 child: _FlexibleSpaceHeaderOpacity(
268 // IOS is relying on this semantics node to correctly traverse
269 // through the app bar when it is collapsed.
270 alwaysIncludeSemantics: true,
271 opacity: opacity,
272 child: widget.background,
273 ),
274 ),
275 );
276
277 // StretchMode.blurBackground
278 if (widget.stretchModes.contains(StretchMode.blurBackground) &&
279 constraints.maxHeight > settings.maxExtent) {
280 final double blurAmount = (constraints.maxHeight - settings.maxExtent) / 10;
281 children.add(
282 Positioned.fill(
283 child: BackdropFilter(
284 filter: ui.ImageFilter.blur(sigmaX: blurAmount, sigmaY: blurAmount),
285 child: const ColoredBox(color: Colors.transparent),
286 ),
287 ),
288 );
289 }
290 }
291
292 // title
293 if (widget.title != null) {
294 final ThemeData theme = Theme.of(context);
295
296 Widget? title;
297 switch (theme.platform) {
298 case TargetPlatform.iOS:
299 case TargetPlatform.macOS:
300 title = widget.title;
301 case TargetPlatform.android:
302 case TargetPlatform.fuchsia:
303 case TargetPlatform.linux:
304 case TargetPlatform.windows:
305 title = Semantics(namesRoute: true, child: widget.title);
306 }
307
308 // StretchMode.fadeTitle
309 if (widget.stretchModes.contains(StretchMode.fadeTitle) &&
310 constraints.maxHeight > settings.maxExtent) {
311 final double stretchOpacity =
312 1 - clampDouble((constraints.maxHeight - settings.maxExtent) / 100, 0.0, 1.0);
313 title = Opacity(opacity: stretchOpacity, child: title);
314 }
315
316 final double opacity = settings.toolbarOpacity;
317 if (opacity > 0.0) {
318 TextStyle titleStyle = theme.useMaterial3
319 ? theme.textTheme.titleLarge!
320 : theme.primaryTextTheme.titleLarge!;
321 titleStyle = titleStyle.copyWith(color: titleStyle.color!.withOpacity(opacity));
322 final bool effectiveCenterTitle = _getEffectiveCenterTitle(theme);
323 final double leadingPadding = (settings.hasLeading ?? true) ? 72.0 : 0.0;
324 final EdgeInsetsGeometry padding =
325 widget.titlePadding ??
326 EdgeInsetsDirectional.only(
327 start: effectiveCenterTitle ? 0.0 : leadingPadding,
328 bottom: 16.0,
329 );
330 final double scaleValue = Tween<double>(
331 begin: widget.expandedTitleScale,
332 end: 1.0,
333 ).transform(t);
334 final Matrix4 scaleTransform = Matrix4.identity()
335 ..scaleByDouble(scaleValue, scaleValue, 1.0, 1);
336 final Alignment titleAlignment = _getTitleAlignment(effectiveCenterTitle);
337 children.add(
338 Padding(
339 padding: padding,
340 child: Transform(
341 alignment: titleAlignment,
342 transform: scaleTransform,
343 child: Align(
344 alignment: titleAlignment,
345 child: DefaultTextStyle(
346 style: titleStyle,
347 child: LayoutBuilder(
348 builder: (BuildContext context, BoxConstraints constraints) {
349 return SizedBox(
350 width: constraints.maxWidth / scaleValue,
351 child: Align(alignment: titleAlignment, child: title),
352 );
353 },
354 ),
355 ),
356 ),
357 ),
358 ),
359 );
360 }
361 }
362
363 return ClipRect(child: Stack(children: children));
364 },
365 );
366 }
367}
368
369/// Provides sizing and opacity information to a [FlexibleSpaceBar].
370///
371/// See also:
372///
373/// * [FlexibleSpaceBar] which creates a flexible space bar.
374class FlexibleSpaceBarSettings extends InheritedWidget {
375 /// Creates a Flexible Space Bar Settings widget.
376 ///
377 /// Used by [Scaffold] and [SliverAppBar]. [child] must have a
378 /// [FlexibleSpaceBar] widget in its tree for the settings to take affect.
379 const FlexibleSpaceBarSettings({
380 super.key,
381 required this.toolbarOpacity,
382 required this.minExtent,
383 required this.maxExtent,
384 required this.currentExtent,
385 required super.child,
386 this.isScrolledUnder,
387 this.hasLeading,
388 }) : assert(minExtent >= 0),
389 assert(maxExtent >= 0),
390 assert(currentExtent >= 0),
391 assert(toolbarOpacity >= 0.0),
392 assert(minExtent <= maxExtent),
393 assert(minExtent <= currentExtent),
394 assert(currentExtent <= maxExtent);
395
396 /// Affects how transparent the text within the toolbar appears.
397 final double toolbarOpacity;
398
399 /// Minimum height of the resulting [FlexibleSpaceBar] when fully collapsed.
400 final double minExtent;
401
402 /// Maximum height of the resulting [FlexibleSpaceBar] when fully expanded.
403 final double maxExtent;
404
405 /// If the [FlexibleSpaceBar.title] or the [FlexibleSpaceBar.background] is
406 /// not null, then this value is used to calculate the relative scale of
407 /// these elements upon initialization.
408 final double currentExtent;
409
410 /// True if the FlexibleSpaceBar overlaps the primary scrollable's contents.
411 ///
412 /// This value is used by the [AppBar] to resolve
413 /// [AppBar.backgroundColor] against [WidgetState.scrolledUnder],
414 /// i.e. to enable apps to specify different colors when content
415 /// has been scrolled up and behind the app bar.
416 ///
417 /// Null if the caller hasn't determined if the FlexibleSpaceBar
418 /// overlaps the primary scrollable's contents.
419 final bool? isScrolledUnder;
420
421 /// True if the FlexibleSpaceBar has a leading widget.
422 ///
423 /// This value is used by the [FlexibleSpaceBar] to determine
424 /// if there should be a gap between the leading widget and
425 /// the title.
426 ///
427 /// Null if the caller hasn't determined if the FlexibleSpaceBar
428 /// has a leading widget.
429 final bool? hasLeading;
430
431 @override
432 bool updateShouldNotify(FlexibleSpaceBarSettings oldWidget) {
433 return toolbarOpacity != oldWidget.toolbarOpacity ||
434 minExtent != oldWidget.minExtent ||
435 maxExtent != oldWidget.maxExtent ||
436 currentExtent != oldWidget.currentExtent ||
437 isScrolledUnder != oldWidget.isScrolledUnder ||
438 hasLeading != oldWidget.hasLeading;
439 }
440}
441
442// We need the child widget to repaint, however both the opacity
443// and potentially `widget.background` can be constant which won't
444// lead to repainting.
445// see: https://github.com/flutter/flutter/issues/127836
446class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget {
447 const _FlexibleSpaceHeaderOpacity({
448 required this.opacity,
449 required super.child,
450 required this.alwaysIncludeSemantics,
451 });
452
453 final double opacity;
454 final bool alwaysIncludeSemantics;
455
456 @override
457 RenderObject createRenderObject(BuildContext context) {
458 return _RenderFlexibleSpaceHeaderOpacity(
459 opacity: opacity,
460 alwaysIncludeSemantics: alwaysIncludeSemantics,
461 );
462 }
463
464 @override
465 void updateRenderObject(
466 BuildContext context,
467 covariant _RenderFlexibleSpaceHeaderOpacity renderObject,
468 ) {
469 renderObject
470 ..alwaysIncludeSemantics = alwaysIncludeSemantics
471 ..opacity = opacity;
472 }
473}
474
475class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity {
476 _RenderFlexibleSpaceHeaderOpacity({super.opacity, super.alwaysIncludeSemantics});
477
478 @override
479 bool get isRepaintBoundary => false;
480
481 @override
482 void paint(PaintingContext context, Offset offset) {
483 if (child == null) {
484 return;
485 }
486 if ((opacity * 255).roundToDouble() <= 0) {
487 layer = null;
488 return;
489 }
490 assert(needsCompositing);
491 layer = context.pushOpacity(
492 offset,
493 (opacity * 255).round(),
494 super.paint,
495 oldLayer: layer as OpacityLayer?,
496 );
497 assert(() {
498 layer!.debugCreator = debugCreator;
499 return true;
500 }());
501 }
502}
503