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

import 'package:flutter/widgets.dart';

import 'banner_theme.dart';
import 'divider.dart';
9
import 'material.dart';
10
import 'scaffold.dart';
11 12
import 'theme.dart';

13 14 15
// Examples can assume:
// late BuildContext context;

16 17 18 19 20 21 22 23
const Duration _materialBannerTransitionDuration = Duration(milliseconds: 250);
const Curve _materialBannerHeightCurve = Curves.fastOutSlowIn;

/// Specify how a [MaterialBanner] was closed.
///
/// The [ScaffoldMessengerState.showMaterialBanner] function returns a
/// [ScaffoldFeatureController]. The value of the controller's closed property
/// is a Future that resolves to a MaterialBannerClosedReason. Applications that need
24
/// to know how a [MaterialBanner] was closed can use this value.
25 26 27 28 29
///
/// Example:
///
/// ```dart
/// ScaffoldMessenger.of(context).showMaterialBanner(
30 31 32 33 34 35
///   const MaterialBanner(
///     content: Text('Message...'),
///     actions: <Widget>[
///       // ...
///     ],
///   )
36
/// ).closed.then((MaterialBannerClosedReason reason) {
37
///    // ...
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
/// });
/// ```
enum MaterialBannerClosedReason {
  /// The material banner was closed through a [SemanticsAction.dismiss].
  dismiss,

  /// The material banner was closed by a user's swipe.
  swipe,

  /// The material banner was closed by the [ScaffoldFeatureController] close callback
  /// or by calling [ScaffoldMessengerState.hideCurrentMaterialBanner] directly.
  hide,

  /// The material banner was closed by a call to [ScaffoldMessengerState.removeCurrentMaterialBanner].
  remove,
}

55 56 57 58 59 60 61
/// A Material Design banner.
///
/// A banner displays an important, succinct message, and provides actions for
/// users to address (or dismiss the banner). A user action is required for it
/// to be dismissed.
///
/// Banners should be displayed at the top of the screen, below a top app bar.
62
/// They are persistent and non-modal, allowing the user to either ignore them or
63 64
/// interact with them at any time.
///
65
/// {@tool dartpad}
66 67
/// Banners placed directly into the widget tree are static.
///
68
/// ** See code in examples/api/lib/material/banner/material_banner.0.dart **
69 70
/// {@end-tool}
///
71
/// {@tool dartpad}
72 73 74
/// MaterialBanner's can also be presented through a [ScaffoldMessenger].
/// Here is an example where ScaffoldMessengerState.showMaterialBanner() is used to show the MaterialBanner.
///
75
/// ** See code in examples/api/lib/material/banner/material_banner.1.dart **
76 77
/// {@end-tool}
///
78 79 80 81
/// The [actions] will be placed beside the [content] if there is only one.
/// Otherwise, the [actions] will be placed below the [content]. Use
/// [forceActionsBelow] to override this behavior.
///
82 83 84 85
/// If the [actions] placed below the [content], they will be laid out in a row.
/// If there isn't sufficient room to display everything, they are laid out
/// in a column instead.
///
86 87 88 89 90
/// The [actions] and [content] must be provided. An optional leading widget
/// (typically an [Image]) can also be provided. The [contentTextStyle] and
/// [backgroundColor] can be provided to customize the banner.
///
/// This widget is unrelated to the widgets library [Banner] widget.
91
class MaterialBanner extends StatefulWidget {
92 93 94
  /// Creates a [MaterialBanner].
  ///
  /// The [actions], [content], and [forceActionsBelow] must be non-null.
95 96
  /// The [actions].length must be greater than 0. The [elevation] must be null or
  /// non-negative.
97
  const MaterialBanner({
98
    super.key,
99
    required this.content,
100
    this.contentTextStyle,
101
    required this.actions,
102
    this.elevation,
103 104 105 106 107
    this.leading,
    this.backgroundColor,
    this.padding,
    this.leadingPadding,
    this.forceActionsBelow = false,
108
    this.overflowAlignment = OverflowBarAlignment.end,
109 110
    this.animation,
    this.onVisible
111 112
  }) : assert(elevation == null || elevation >= 0.0),
       assert(content != null),
113
       assert(actions != null),
114
       assert(forceActionsBelow != null);
115 116 117 118 119 120 121 122 123

  /// The content of the [MaterialBanner].
  ///
  /// Typically a [Text] widget.
  final Widget content;

  /// Style for the text in the [content] of the [MaterialBanner].
  ///
  /// If `null`, [MaterialBannerThemeData.contentTextStyle] is used. If that is
124
  /// also `null`, [TextTheme.bodyText2] of [ThemeData.textTheme] is used.
125
  final TextStyle? contentTextStyle;
126 127 128 129

  /// The set of actions that are displayed at the bottom or trailing side of
  /// the [MaterialBanner].
  ///
130
  /// Typically this is a list of [TextButton] widgets.
131 132
  final List<Widget> actions;

133 134 135 136 137 138 139 140 141 142 143 144
  /// The z-coordinate at which to place the material banner.
  ///
  /// This controls the size of the shadow below the material banner.
  ///
  /// Defines the banner's [Material.elevation].
  ///
  /// If this property is null, then [MaterialBannerThemeData.elevation] of
  /// [ThemeData.bannerTheme] is used, if that is also null, the default value is 0.
  /// If the elevation is 0, the [Scaffold]'s body will be pushed down by the
  /// MaterialBanner when used with [ScaffoldMessenger].
  final double? elevation;

145 146 147
  /// The (optional) leading widget of the [MaterialBanner].
  ///
  /// Typically an [Icon] widget.
148
  final Widget? leading;
149 150 151 152

  /// The color of the surface of this [MaterialBanner].
  ///
  /// If `null`, [MaterialBannerThemeData.backgroundColor] is used. If that is
153
  /// also `null`, [ColorScheme.surface] of [ThemeData.colorScheme] is used.
154
  final Color? backgroundColor;
155 156 157 158 159 160 161 162

  /// The amount of space by which to inset the [content].
  ///
  /// If the [actions] are below the [content], this defaults to
  /// `EdgeInsetsDirectional.only(start: 16.0, top: 24.0, end: 16.0, bottom: 4.0)`.
  ///
  /// If the [actions] are trailing the [content], this defaults to
  /// `EdgeInsetsDirectional.only(start: 16.0, top: 2.0)`.
163
  final EdgeInsetsGeometry? padding;
164 165 166 167

  /// The amount of space by which to inset the [leading] widget.
  ///
  /// This defaults to `EdgeInsetsDirectional.only(end: 16.0)`.
168
  final EdgeInsetsGeometry? leadingPadding;
169 170 171 172

  /// An override to force the [actions] to be below the [content] regardless of
  /// how many there are.
  ///
173 174 175 176
  /// If this is true, the [actions] will be placed below the [content]. If
  /// this is false, the [actions] will be placed on the trailing side of the
  /// [content] if [actions]'s length is 1 and below the [content] if greater
  /// than 1.
177 178
  ///
  /// Defaults to false.
179 180
  final bool forceActionsBelow;

181 182 183 184 185
  /// The horizontal alignment of the [actions] when the [actions] laid out in a column.
  ///
  /// Defaults to [OverflowBarAlignment.end].
  final OverflowBarAlignment overflowAlignment;

186 187 188 189 190 191 192 193
  /// The animation driving the entrance and exit of the material banner when presented by the [ScaffoldMessenger].
  final Animation<double>? animation;

  /// Called the first time that the material banner is visible within a [Scaffold] when presented by the [ScaffoldMessenger].
  final VoidCallback? onVisible;

  // API for ScaffoldMessengerState.showMaterialBanner():

194
  /// Creates an animation controller useful for driving a [MaterialBanner]'s entrance and exit animation.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
  static AnimationController createAnimationController({ required TickerProvider vsync }) {
    return AnimationController(
      duration: _materialBannerTransitionDuration,
      debugLabel: 'MaterialBanner',
      vsync: vsync,
    );
  }

  /// Creates a copy of this material banner but with the animation replaced with the given animation.
  ///
  /// If the original material banner lacks a key, the newly created material banner will
  /// use the given fallback key.
  MaterialBanner withAnimation(Animation<double> newAnimation, { Key? fallbackKey }) {
    return MaterialBanner(
      key: key ?? fallbackKey,
      content: content,
      contentTextStyle: contentTextStyle,
      actions: actions,
213
      elevation: elevation,
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239
      leading: leading,
      backgroundColor: backgroundColor,
      padding: padding,
      leadingPadding: leadingPadding,
      forceActionsBelow: forceActionsBelow,
      overflowAlignment: overflowAlignment,
      animation: newAnimation,
      onVisible: onVisible,
    );
  }

  @override
  State<MaterialBanner> createState() => _MaterialBannerState();
}

class _MaterialBannerState extends State<MaterialBanner> {
  bool _wasVisible = false;

  @override
  void initState() {
    super.initState();
    widget.animation?.addStatusListener(_onAnimationStatusChanged);
  }

  @override
  void didUpdateWidget(MaterialBanner oldWidget) {
240
    super.didUpdateWidget(oldWidget);
241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266
    if (widget.animation != oldWidget.animation) {
      oldWidget.animation?.removeStatusListener(_onAnimationStatusChanged);
      widget.animation?.addStatusListener(_onAnimationStatusChanged);
    }
  }

  @override
  void dispose() {
    widget.animation?.removeStatusListener(_onAnimationStatusChanged);
    super.dispose();
  }

  void _onAnimationStatusChanged(AnimationStatus animationStatus) {
    switch (animationStatus) {
      case AnimationStatus.dismissed:
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
        break;
      case AnimationStatus.completed:
        if (widget.onVisible != null && !_wasVisible) {
          widget.onVisible!();
        }
        _wasVisible = true;
    }
  }

267 268
  @override
  Widget build(BuildContext context) {
269 270 271 272
    assert(debugCheckHasMediaQuery(context));
    final MediaQueryData mediaQueryData = MediaQuery.of(context);

    assert(widget.actions.isNotEmpty);
273

274
    final ThemeData theme = Theme.of(context);
275 276
    final MaterialBannerThemeData bannerTheme = MaterialBannerTheme.of(context);

277 278
    final bool isSingleRow = widget.actions.length == 1 && !widget.forceActionsBelow;
    final EdgeInsetsGeometry padding = widget.padding ?? bannerTheme.padding ?? (isSingleRow
279 280
        ? const EdgeInsetsDirectional.only(start: 16.0, top: 2.0)
        : const EdgeInsetsDirectional.only(start: 16.0, top: 24.0, end: 16.0, bottom: 4.0));
281
    final EdgeInsetsGeometry leadingPadding = widget.leadingPadding
282
        ?? bannerTheme.leadingPadding
283 284
        ?? const EdgeInsetsDirectional.only(end: 16.0);

285 286 287 288 289
    final Widget buttonBar = Container(
      alignment: AlignmentDirectional.centerEnd,
      constraints: const BoxConstraints(minHeight: 52.0),
      padding: const EdgeInsets.symmetric(horizontal: 8),
      child: OverflowBar(
290
        overflowAlignment: widget.overflowAlignment,
291
        spacing: 8,
292
        children: widget.actions,
293
      ),
294 295
    );

296
    final double elevation = widget.elevation ?? bannerTheme.elevation ?? 0.0;
297
    final Color backgroundColor = widget.backgroundColor
298
        ?? bannerTheme.backgroundColor
299
        ?? theme.colorScheme.surface;
300
    final TextStyle? textStyle = widget.contentTextStyle
301
        ?? bannerTheme.contentTextStyle
302
        ?? theme.textTheme.bodyText2;
303

304
    Widget materialBanner = Container(
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
      margin: EdgeInsets.only(bottom: elevation > 0 ? 10.0 : 0.0),
      child: Material(
        elevation: elevation,
        color: backgroundColor,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Padding(
              padding: padding,
              child: Row(
                children: <Widget>[
                  if (widget.leading != null)
                    Padding(
                      padding: leadingPadding,
                      child: widget.leading,
                    ),
                  Expanded(
                    child: DefaultTextStyle(
                      style: textStyle!,
                      child: widget.content,
                    ),
326
                  ),
327 328 329 330
                  if (isSingleRow)
                    buttonBar,
                ],
              ),
331
            ),
332 333 334 335 336 337 338
            if (!isSingleRow)
              buttonBar,

            if (elevation == 0)
              const Divider(height: 0),
          ],
        ),
339 340
      ),
    );
341 342

    // This provides a static banner for backwards compatibility.
343
    if (widget.animation == null) {
344
      return materialBanner;
345
    }
346

347 348 349 350
    materialBanner = SafeArea(
      child: materialBanner,
    );

351
    final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation!, curve: _materialBannerHeightCurve);
352 353 354 355
    final Animation<Offset> slideOutAnimation = Tween<Offset>(
      begin: const Offset(0.0, -1.0),
      end: Offset.zero,
    ).animate(CurvedAnimation(
356
      parent: widget.animation!,
357 358
      curve: const Threshold(0.0),
    ));
359 360 361 362 363 364 365 366 367

    materialBanner = Semantics(
      container: true,
      liveRegion: true,
      onDismiss: () {
        ScaffoldMessenger.of(context).removeCurrentMaterialBanner(reason: MaterialBannerClosedReason.dismiss);
      },
      child: mediaQueryData.accessibleNavigation
          ? materialBanner
368 369
          : SlideTransition(
        position: slideOutAnimation,
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392
        child: materialBanner,
      ),
    );

    final Widget materialBannerTransition;
    if (mediaQueryData.accessibleNavigation) {
      materialBannerTransition = materialBanner;
    } else {
      materialBannerTransition = AnimatedBuilder(
        animation: heightAnimation,
        builder: (BuildContext context, Widget? child) {
          return Align(
            alignment: AlignmentDirectional.bottomStart,
            heightFactor: heightAnimation.value,
            child: child,
          );
        },
        child: materialBanner,
      );
    }

    return Hero(
      tag: '<MaterialBanner Hero tag - ${widget.content}>',
393
      child: ClipRect(child: materialBannerTransition),
394
    );
395
  }
396
}