flexible_space_bar.dart 17.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:math' as math;
6
import 'dart:ui' as ui;
7 8 9

import 'package:flutter/widgets.dart';

10
import 'colors.dart';
11 12 13
import 'constants.dart';
import 'theme.dart';

14
/// The collapsing effect while the space bar collapses from its full size.
15 16 17 18 19 20 21 22 23 24 25
enum CollapseMode {
  /// The background widget will scroll in a parallax fashion.
  parallax,

  /// The background widget pin in place until it reaches the min extent.
  pin,

  /// The background widget will act as normal with no collapsing effect.
  none,
}

26 27 28 29 30 31 32 33 34 35 36 37 38 39
/// The stretching effect while the space bar stretches beyond its full size.
enum StretchMode {
  /// The background widget will expand to fill the extra space.
  zoomBackground,

  /// The background will blur using a [ImageFilter.blur] effect.
  blurBackground,

  /// The title will fade away as the user over-scrolls.
  fadeTitle,
}

/// The part of a material design [AppBar] that expands, collapses, and
/// stretches.
40
///
41 42
/// {@youtube 560 315 https://www.youtube.com/watch?v=mSc7qFzxHDw}
///
43
/// Most commonly used in the [SliverAppBar.flexibleSpace] field, a flexible
44 45
/// space bar expands and contracts as the app scrolls so that the [AppBar]
/// reaches from the top of the app to the top of the scrolling contents of the
46 47 48 49
/// app. When using [SliverAppBar.flexibleSpace], the [SliverAppBar.expandedHeight]
/// must be large enough to accommodate the [SliverAppBar.flexibleSpace] widget.
///
/// Furthermore is included functionality for stretch behavior. When
50 51
/// [SliverAppBar.stretch] is true, and your [ScrollPhysics] allow for
/// overscroll, this space will stretch with the overscroll.
52
///
53 54 55
/// The widget that sizes the [AppBar] must wrap it in the widget returned by
/// [FlexibleSpaceBar.createSettings], to convey sizing information down to the
/// [FlexibleSpaceBar].
56
///
57
/// {@tool dartpad --template=freeform}
58 59 60 61 62 63 64 65 66 67 68
/// This sample application demonstrates the different features of the
/// [FlexibleSpaceBar] when used in a [SliverAppBar]. This app bar is configured
/// to stretch into the overscroll space, and uses the
/// [FlexibleSpaceBar.stretchModes] to apply `fadeTitle`, `blurBackground` and
/// `zoomBackground`. The app bar also makes use of [CollapseMode.parallax] by
/// default.
///
/// ```dart imports
/// import 'package:flutter/material.dart';
/// ```
/// ```dart
69
/// void main() => runApp(const MaterialApp(home: MyApp()));
70 71
///
/// class MyApp extends StatelessWidget {
72 73
///   const MyApp({Key? key}) : super(key: key);
///
74 75 76 77
///   @override
///   Widget build(BuildContext context) {
///     return Scaffold(
///       body: CustomScrollView(
78
///         physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
79 80 81 82 83
///         slivers: <Widget>[
///           SliverAppBar(
///             stretch: true,
///             onStretchTrigger: () {
///               // Function callback for stretch
84
///               return Future<void>.value();
85 86 87
///             },
///             expandedHeight: 300.0,
///             flexibleSpace: FlexibleSpaceBar(
88
///               stretchModes: const <StretchMode>[
89 90 91 92 93 94 95 96
///                 StretchMode.zoomBackground,
///                 StretchMode.blurBackground,
///                 StretchMode.fadeTitle,
///               ],
///               centerTitle: true,
///               title: const Text('Flight Report'),
///               background: Stack(
///                 fit: StackFit.expand,
97
///                 children: <Widget>[
98 99 100 101 102 103 104 105
///                   Image.network(
///                     'https://flutter.github.io/assets-for-api-docs/assets/widgets/owl-2.jpg',
///                     fit: BoxFit.cover,
///                   ),
///                   const DecoratedBox(
///                     decoration: BoxDecoration(
///                       gradient: LinearGradient(
///                         begin: Alignment(0.0, 0.5),
106
///                         end: Alignment.center,
107 108 109 110 111 112 113 114 115 116 117 118
///                         colors: <Color>[
///                           Color(0x60000000),
///                           Color(0x00000000),
///                         ],
///                       ),
///                     ),
///                   ),
///                 ],
///               ),
///             ),
///           ),
///           SliverList(
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
///             delegate: SliverChildListDelegate(
///               const <Widget>[
///                 ListTile(
///                   leading: Icon(Icons.wb_sunny),
///                   title: Text('Sunday'),
///                   subtitle: Text('sunny, h: 80, l: 65'),
///                 ),
///                 ListTile(
///                   leading: Icon(Icons.wb_sunny),
///                   title: Text('Monday'),
///                   subtitle: Text('sunny, h: 80, l: 65'),
///                 ),
///                 // ListTiles++
///               ],
///             ),
134 135 136 137 138 139 140 141 142 143
///           ),
///         ],
///       ),
///     );
///   }
/// }
///
/// ```
/// {@end-tool}
///
144
/// See also:
Ian Hickson's avatar
Ian Hickson committed
145
///
146 147
///  * [SliverAppBar], which implements the expanding and contracting.
///  * [AppBar], which is used by [SliverAppBar].
148
///  * <https://material.io/design/components/app-bars-top.html#behavior>
149
class FlexibleSpaceBar extends StatefulWidget {
150 151
  /// Creates a flexible space bar.
  ///
152
  /// Most commonly used in the [AppBar.flexibleSpace] field.
153
  const FlexibleSpaceBar({
154
    Key? key,
155 156
    this.title,
    this.background,
157
    this.centerTitle,
158
    this.titlePadding,
159
    this.collapseMode = CollapseMode.parallax,
160
    this.stretchModes = const <StretchMode>[StretchMode.zoomBackground],
161 162
  }) : assert(collapseMode != null),
       super(key: key);
163

164 165 166
  /// The primary contents of the flexible space bar when expanded.
  ///
  /// Typically a [Text] widget.
167
  final Widget? title;
168 169 170

  /// Shown behind the [title] when expanded.
  ///
171
  /// Typically an [Image] widget with [Image.fit] set to [BoxFit.cover].
172
  final Widget? background;
173

174 175
  /// Whether the title should be centered.
  ///
176
  /// By default this property is true if the current target platform
177
  /// is [TargetPlatform.iOS] or [TargetPlatform.macOS], false otherwise.
178
  final bool? centerTitle;
179

180 181 182 183 184
  /// Collapse effect while scrolling.
  ///
  /// Defaults to [CollapseMode.parallax].
  final CollapseMode collapseMode;

185
  /// Stretch effect while over-scrolling.
186 187 188 189
  ///
  /// Defaults to include [StretchMode.zoomBackground].
  final List<StretchMode> stretchModes;

190 191 192 193 194 195 196 197 198
  /// Defines how far the [title] is inset from either the widget's
  /// bottom-left or its center.
  ///
  /// Typically this property is used to adjust how far the title is
  /// is inset from the bottom-left and it is specified along with
  /// [centerTitle] false.
  ///
  /// By default the value of this property is
  /// `EdgeInsetsDirectional.only(start: 72, bottom: 16)` if the title is
199
  /// not centered, `EdgeInsetsDirectional.only(start: 0, bottom: 16)` otherwise.
200
  final EdgeInsetsGeometry? titlePadding;
201

202 203 204 205
  /// Wraps a widget that contains an [AppBar] to convey sizing information down
  /// to the [FlexibleSpaceBar].
  ///
  /// Used by [Scaffold] and [SliverAppBar].
206 207 208 209 210 211 212
  ///
  /// `toolbarOpacity` affects how transparent the text within the toolbar
  /// appears. `minExtent` sets the minimum height of the resulting
  /// [FlexibleSpaceBar] when fully collapsed. `maxExtent` sets the maximum
  /// height of the resulting [FlexibleSpaceBar] when fully expanded.
  /// `currentExtent` sets the scale of the [FlexibleSpaceBar.background] and
  /// [FlexibleSpaceBar.title] widgets of [FlexibleSpaceBar] upon
213
  /// initialization. `scrolledUnder` is true if the [FlexibleSpaceBar]
214 215
  /// overlaps the app's primary scrollable, false if it does not, and null
  /// if the caller has not determined as much.
216 217
  /// See also:
  ///
218 219
  ///  * [FlexibleSpaceBarSettings] which creates a settings object that can be
  ///    used to specify these settings to a [FlexibleSpaceBar].
220
  static Widget createSettings({
221 222 223
    double? toolbarOpacity,
    double? minExtent,
    double? maxExtent,
224
    bool? isScrolledUnder,
225 226
    required double currentExtent,
    required Widget child,
227 228
  }) {
    assert(currentExtent != null);
229
    return FlexibleSpaceBarSettings(
230 231 232
      toolbarOpacity: toolbarOpacity ?? 1.0,
      minExtent: minExtent ?? currentExtent,
      maxExtent: maxExtent ?? currentExtent,
233
      isScrolledUnder: isScrolledUnder,
234 235 236 237 238
      currentExtent: currentExtent,
      child: child,
    );
  }

239
  @override
240
  State<FlexibleSpaceBar> createState() => _FlexibleSpaceBarState();
241 242 243
}

class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
244
  bool _getEffectiveCenterTitle(ThemeData theme) {
245
    if (widget.centerTitle != null)
246
      return widget.centerTitle!;
247 248 249
    assert(theme.platform != null);
    switch (theme.platform) {
      case TargetPlatform.android:
250
      case TargetPlatform.fuchsia:
251 252
      case TargetPlatform.linux:
      case TargetPlatform.windows:
253 254
        return false;
      case TargetPlatform.iOS:
255
      case TargetPlatform.macOS:
256 257 258 259
        return true;
    }
  }

260
  Alignment _getTitleAlignment(bool effectiveCenterTitle) {
261
    if (effectiveCenterTitle)
262
      return Alignment.bottomCenter;
263
    final TextDirection textDirection = Directionality.of(context);
264 265 266
    assert(textDirection != null);
    switch (textDirection) {
      case TextDirection.rtl:
267
        return Alignment.bottomRight;
268
      case TextDirection.ltr:
269
        return Alignment.bottomLeft;
270 271 272
    }
  }

273
  double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) {
274 275 276 277 278 279 280
    switch (widget.collapseMode) {
      case CollapseMode.pin:
        return -(settings.maxExtent - settings.currentExtent);
      case CollapseMode.none:
        return 0.0;
      case CollapseMode.parallax:
        final double deltaExtent = settings.maxExtent - settings.minExtent;
281
        return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
282 283 284
    }
  }

285 286
  @override
  Widget build(BuildContext context) {
287 288
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
289
        final FlexibleSpaceBarSettings settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
290 291 292 293
        assert(
          settings != null,
          'A FlexibleSpaceBar must be wrapped in the widget returned by FlexibleSpaceBar.createSettings().',
        );
294

295
        final List<Widget> children = <Widget>[];
296

297 298 299 300
        final double deltaExtent = settings.maxExtent - settings.minExtent;

        // 0.0 -> Expanded
        // 1.0 -> Collapsed to toolbar
301
        final double t = (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
302 303 304 305 306 307

        // background
        if (widget.background != null) {
          final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
          const double fadeEnd = 1.0;
          assert(fadeStart <= fadeEnd);
308 309 310 311 312
          // If the min and max extent are the same, the app bar cannot collapse
          // and the content should be visible, so opacity = 1.
          final double opacity = settings.maxExtent == settings.minExtent
              ? 1.0
              : 1.0 - Interval(fadeStart, fadeEnd).transform(t);
313
          double height = settings.maxExtent;
314

315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
          // StretchMode.zoomBackground
          if (widget.stretchModes.contains(StretchMode.zoomBackground) &&
            constraints.maxHeight > height) {
            height = constraints.maxHeight;
          }
          children.add(Positioned(
            top: _getCollapsePadding(t, settings),
            left: 0.0,
            right: 0.0,
            height: height,
            child: Opacity(
              // IOS is relying on this semantics node to correctly traverse
              // through the app bar when it is collapsed.
              alwaysIncludeSemantics: true,
              opacity: opacity,
              child: widget.background,
            ),
          ));

          // StretchMode.blurBackground
          if (widget.stretchModes.contains(StretchMode.blurBackground) &&
            constraints.maxHeight > settings.maxExtent) {
            final double blurAmount = (constraints.maxHeight - settings.maxExtent) / 10;
            children.add(Positioned.fill(
              child: BackdropFilter(
                filter: ui.ImageFilter.blur(
                  sigmaX: blurAmount,
                  sigmaY: blurAmount,
343
                ),
344 345 346
                child: Container(
                  color: Colors.transparent,
                ),
347
              ),
348
            ));
349 350
          }
        }
351

352 353
        // title
        if (widget.title != null) {
354
          final ThemeData theme = Theme.of(context);
355

356
          Widget? title;
357 358
          switch (theme.platform) {
            case TargetPlatform.iOS:
359
            case TargetPlatform.macOS:
360 361 362
              title = widget.title;
              break;
            case TargetPlatform.android:
363 364 365
            case TargetPlatform.fuchsia:
            case TargetPlatform.linux:
            case TargetPlatform.windows:
366 367 368 369
              title = Semantics(
                namesRoute: true,
                child: widget.title,
              );
370
              break;
371 372 373 374 375 376
          }

          // StretchMode.fadeTitle
          if (widget.stretchModes.contains(StretchMode.fadeTitle) &&
            constraints.maxHeight > settings.maxExtent) {
            final double stretchOpacity = 1 -
377
              (((constraints.maxHeight - settings.maxExtent) / 100).clamp(0.0, 1.0));
378 379 380 381 382 383 384 385
            title = Opacity(
              opacity: stretchOpacity,
              child: title,
            );
          }

          final double opacity = settings.toolbarOpacity;
          if (opacity > 0.0) {
386
            TextStyle titleStyle = theme.primaryTextTheme.headline6!;
387
            titleStyle = titleStyle.copyWith(
388
              color: titleStyle.color!.withOpacity(opacity),
389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408
            );
            final bool effectiveCenterTitle = _getEffectiveCenterTitle(theme);
            final EdgeInsetsGeometry padding = widget.titlePadding ??
              EdgeInsetsDirectional.only(
                start: effectiveCenterTitle ? 0.0 : 72.0,
                bottom: 16.0,
              );
            final double scaleValue = Tween<double>(begin: 1.5, end: 1.0).transform(t);
            final Matrix4 scaleTransform = Matrix4.identity()
              ..scale(scaleValue, scaleValue, 1.0);
            final Alignment titleAlignment = _getTitleAlignment(effectiveCenterTitle);
            children.add(Container(
              padding: padding,
              child: Transform(
                alignment: titleAlignment,
                transform: scaleTransform,
                child: Align(
                  alignment: titleAlignment,
                  child: DefaultTextStyle(
                    style: titleStyle,
409 410 411 412 413 414 415
                    child: LayoutBuilder(
                      builder: (BuildContext context, BoxConstraints constraints) {
                        return Container(
                          width: constraints.maxWidth / scaleValue,
                          alignment: titleAlignment,
                          child: title,
                        );
416
                      },
417
                    ),
418 419 420 421 422 423 424 425
                  ),
                ),
              ),
            ));
          }
        }

        return ClipRect(child: Stack(children: children));
426
      },
427
    );
428
  }
429 430
}

431 432 433 434
/// Provides sizing and opacity information to a [FlexibleSpaceBar].
///
/// See also:
///
435
///  * [FlexibleSpaceBar] which creates a flexible space bar.
436 437 438 439 440
class FlexibleSpaceBarSettings extends InheritedWidget {
  /// Creates a Flexible Space Bar Settings widget.
  ///
  /// Used by [Scaffold] and [SliverAppBar]. [child] must have a
  /// [FlexibleSpaceBar] widget in its tree for the settings to take affect.
441 442 443
  ///
  /// The required [toolbarOpacity], [minExtent], [maxExtent], [currentExtent],
  /// and [child] parameters must not be null.
444
  const FlexibleSpaceBarSettings({
445 446 447 448 449 450
    Key? key,
    required this.toolbarOpacity,
    required this.minExtent,
    required this.maxExtent,
    required this.currentExtent,
    required Widget child,
451
    this.isScrolledUnder,
452 453 454 455 456 457 458 459
  }) : assert(toolbarOpacity != null),
       assert(minExtent != null && minExtent >= 0),
       assert(maxExtent != null && maxExtent >= 0),
       assert(currentExtent != null && currentExtent >= 0),
       assert(toolbarOpacity >= 0.0),
       assert(minExtent <= maxExtent),
       assert(minExtent <= currentExtent),
       assert(currentExtent <= maxExtent),
460
       super(key: key, child: child);
461

462
  /// Affects how transparent the text within the toolbar appears.
463
  final double toolbarOpacity;
464 465

  /// Minimum height of the resulting [FlexibleSpaceBar] when fully collapsed.
466
  final double minExtent;
467 468

  /// Maximum height of the resulting [FlexibleSpaceBar] when fully expanded.
469
  final double maxExtent;
470 471 472 473

  /// If the [FlexibleSpaceBar.title] or the [FlexibleSpaceBar.background] is
  /// not null, then this value is used to calculate the relative scale of
  /// these elements upon initialization.
474
  final double currentExtent;
475

476 477 478 479 480 481 482 483 484 485 486
  /// True if the FlexibleSpaceBar overlaps the primary scrollable's contents.
  ///
  /// This value is used by the [AppBar] to resolve
  /// [AppBar.backgroundColor] against [MaterialState.scrolledUnder],
  /// i.e.  to enable apps to specify different colors when content
  /// has been scrolled up and behind the app bar.
  ///
  /// Null if the caller hasn't determined if the FlexibleSpaceBar
  /// overlaps the primary scrollable's contents.
  final bool? isScrolledUnder;

487
  @override
488
  bool updateShouldNotify(FlexibleSpaceBarSettings oldWidget) {
489 490 491
    return toolbarOpacity != oldWidget.toolbarOpacity
        || minExtent != oldWidget.minExtent
        || maxExtent != oldWidget.maxExtent
492 493
        || currentExtent != oldWidget.currentExtent
        || isScrolledUnder != oldWidget.isScrolledUnder;
494
  }
495
}