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

5 6
// @dart = 2.8

7
import 'package:flutter/rendering.dart';
8
import 'package:flutter/widgets.dart';
9

10
import 'button_theme.dart';
11
import 'color_scheme.dart';
12
import 'flat_button.dart';
13
import 'material.dart';
14
import 'scaffold.dart';
15
import 'snack_bar_theme.dart';
16
import 'theme.dart';
17
import 'theme_data.dart';
Matt Perry's avatar
Matt Perry committed
18

19
const double _singleLineVerticalPadding = 14.0;
Matt Perry's avatar
Matt Perry committed
20

Hixie's avatar
Hixie committed
21 22
// TODO(ianh): We should check if the given text and actions are going to fit on
// one line or not, and if they are, use the single-line layout, and if not, use
23 24 25
// the multiline layout, https://github.com/flutter/flutter/issues/32782
// See https://material.io/components/snackbars#specs, 'Longer Action Text' does
// not match spec.
Hixie's avatar
Hixie committed
26

27 28
const Duration _snackBarTransitionDuration = Duration(milliseconds: 250);
const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000);
29
const Curve _snackBarHeightCurve = Curves.fastOutSlowIn;
30 31
const Curve _snackBarFadeInCurve = Interval(0.45, 1.0, curve: Curves.fastOutSlowIn);
const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn);
Hixie's avatar
Hixie committed
32

33 34
/// Specify how a [SnackBar] was closed.
///
35
/// The [ScaffoldState.showSnackBar] function returns a
36 37 38
/// [ScaffoldFeatureController]. The value of the controller's closed property
/// is a Future that resolves to a SnackBarClosedReason. Applications that need
/// to know how a snackbar was closed can use this value.
39 40 41 42
///
/// Example:
///
/// ```dart
43
/// Scaffold.of(context).showSnackBar(
44
///   SnackBar( ... )
45 46 47 48 49 50 51 52
/// ).closed.then((SnackBarClosedReason reason) {
///    ...
/// });
/// ```
enum SnackBarClosedReason {
  /// The snack bar was closed after the user tapped a [SnackBarAction].
  action,

53
  /// The snack bar was closed through a [SemanticsAction.dismiss].
54 55
  dismiss,

56 57 58 59
  /// The snack bar was closed by a user's swipe.
  swipe,

  /// The snack bar was closed by the [ScaffoldFeatureController] close callback
60
  /// or by calling [ScaffoldState.hideCurrentSnackBar] directly.
61 62
  hide,

63
  /// The snack bar was closed by an call to [ScaffoldState.removeCurrentSnackBar].
64 65 66 67 68 69
  remove,

  /// The snack bar was closed because its timer expired.
  timeout,
}

70 71 72 73 74
/// A button for a [SnackBar], known as an "action".
///
/// Snack bar actions are always enabled. If you want to disable a snack bar
/// action, simply don't include it in the snack bar.
///
75 76
/// Snack bar actions can only be pressed once. Subsequent presses are ignored.
///
77 78 79
/// See also:
///
///  * [SnackBar]
jslavitz's avatar
jslavitz committed
80
///  * <https://material.io/design/components/snackbars.html>
81
class SnackBarAction extends StatefulWidget {
82 83 84
  /// Creates an action for a [SnackBar].
  ///
  /// The [label] and [onPressed] arguments must be non-null.
85
  const SnackBarAction({
86
    Key key,
jslavitz's avatar
jslavitz committed
87 88
    this.textColor,
    this.disabledTextColor,
89
    @required this.label,
90
    @required this.onPressed,
91 92 93
  }) : assert(label != null),
       assert(onPressed != null),
       super(key: key);
94

95 96
  /// The button label color. If not provided, defaults to
  /// [SnackBarThemeData.actionTextColor].
jslavitz's avatar
jslavitz committed
97 98 99
  final Color textColor;

  /// The button disabled label color. This color is shown after the
100
  /// [SnackBarAction] is dismissed.
jslavitz's avatar
jslavitz committed
101 102
  final Color disabledTextColor;

103
  /// The button label.
104
  final String label;
105

106
  /// The callback to be called when the button is pressed. Must not be null.
107
  ///
108
  /// This callback will be called at most once each time this action is
109
  /// displayed in a [SnackBar].
110
  final VoidCallback onPressed;
111

112
  @override
113
  State<SnackBarAction> createState() => _SnackBarActionState();
114 115 116 117 118 119 120 121 122 123 124
}

class _SnackBarActionState extends State<SnackBarAction> {
  bool _haveTriggeredAction = false;

  void _handlePressed() {
    if (_haveTriggeredAction)
      return;
    setState(() {
      _haveTriggeredAction = true;
    });
125
    widget.onPressed();
126
    Scaffold.of(context).hideCurrentSnackBar(reason: SnackBarClosedReason.action);
127 128
  }

129
  @override
Hixie's avatar
Hixie committed
130
  Widget build(BuildContext context) {
131 132 133 134
    final SnackBarThemeData snackBarTheme = Theme.of(context).snackBarTheme;
    final Color textColor = widget.textColor ?? snackBarTheme.actionTextColor;
    final Color disabledTextColor = widget.disabledTextColor ?? snackBarTheme.disabledActionTextColor;

135
    return FlatButton(
136
      onPressed: _haveTriggeredAction ? null : _handlePressed,
137
      child: Text(widget.label),
138 139
      textColor: textColor,
      disabledTextColor: disabledTextColor,
140 141 142
    );
  }
}
143

144 145 146
/// A lightweight message with an optional action which briefly displays at the
/// bottom of the screen.
///
147 148
/// {@youtube 560 315 https://www.youtube.com/watch?v=zpO6n_oZWw0}
///
149 150
/// To display a snack bar, call `Scaffold.of(context).showSnackBar()`, passing
/// an instance of [SnackBar] that describes the message.
151 152
///
/// To control how long the [SnackBar] remains visible, specify a [duration].
153
///
154 155 156
/// A SnackBar with an action will not time out when TalkBack or VoiceOver are
/// enabled. This is controlled by [AccessibilityFeatures.accessibleNavigation].
///
157
/// See also:
158
///
159 160 161 162 163
///  * [Scaffold.of], to obtain the current [ScaffoldState], which manages the
///    display and animation of snack bars.
///  * [ScaffoldState.showSnackBar], which displays a [SnackBar].
///  * [ScaffoldState.removeCurrentSnackBar], which abruptly hides the currently
///    displayed snack bar, if any, and allows the next to be displayed.
164 165
///  * [SnackBarAction], which is used to specify an [action] button to show
///    on the snack bar.
166 167
///  * [SnackBarThemeData], to configure the default property values for
///    [SnackBar] widgets.
jslavitz's avatar
jslavitz committed
168
///  * <https://material.io/design/components/snackbars.html>
169
class SnackBar extends StatefulWidget {
170 171
  /// Creates a snack bar.
  ///
172 173
  /// The [content] argument must be non-null. The [elevation] must be null or
  /// non-negative.
174
  const SnackBar({
175
    Key key,
176
    @required this.content,
177
    this.backgroundColor,
178
    this.elevation,
179 180 181
    this.margin,
    this.padding,
    this.width,
182 183
    this.shape,
    this.behavior,
184
    this.action,
185
    this.duration = _snackBarDisplayDuration,
186
    this.animation,
187
    this.onVisible,
188 189
  }) : assert(elevation == null || elevation >= 0.0),
       assert(content != null),
190 191 192 193 194 195 196 197 198 199 200 201
       assert(
         margin == null || behavior == SnackBarBehavior.floating,
         'Margin can only be used with floating behavior',
       ),
       assert(
         width == null || behavior == SnackBarBehavior.floating,
         'Width can only be used with floating behavior',
       ),
       assert(
         width == null || margin == null,
         'Width and margin can not be used together',
       ),
202
       assert(duration != null),
203
       super(key: key);
204

205 206 207
  /// The primary content of the snack bar.
  ///
  /// Typically a [Text] widget.
208
  final Widget content;
209

210
  /// The snack bar's background color. If not specified it will use
211 212 213 214
  /// [SnackBarThemeData.backgroundColor] of [ThemeData.snackBarTheme]. If that
  /// is not specified it will default to a dark variation of
  /// [ColorScheme.surface] for light themes, or [ColorScheme.onSurface] for
  /// dark themes.
215 216
  final Color backgroundColor;

217 218 219 220 221
  /// The z-coordinate at which to place the snack bar. This controls the size
  /// of the shadow below the snack bar.
  ///
  /// Defines the card's [Material.elevation].
  ///
222 223 224
  /// If this property is null, then [SnackBarThemeData.elevation] of
  /// [ThemeData.snackBarTheme] is used, if that is also null, the default value
  /// is 6.0.
225 226
  final double elevation;

227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254
  /// Empty space to surround the snack bar.
  ///
  /// This property is only used when [behavior] is [SnackBarBehavior.floating].
  /// It can not be used if [width] is specified.
  ///
  /// If this property is null, then the default is
  /// `EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0)`.
  final EdgeInsetsGeometry margin;

  /// The amount of padding to apply to the snack bar's content and optional
  /// action.
  ///
  /// If this property is null, then the default depends on the [behavior] and
  /// the presence of an [action]. The start padding is 24 if [behavior] is
  /// [SnackBarBehavior.fixed] and 16 if it is [SnackBarBehavior.floating]. If
  /// there is no [action], the same padding is added to the end.
  final EdgeInsetsGeometry padding;

  /// The width of the snack bar.
  ///
  /// If width is specified, the snack bar will be centered horizontally in the
  /// available space. This property is only used when [behavior] is
  /// [SnackBarBehavior.floating]. It can not be used if [margin] is specified.
  ///
  /// If this property is null, then the snack bar will take up the full device
  /// width less the margin.
  final double width;

255 256 257 258
  /// The shape of the snack bar's [Material].
  ///
  /// Defines the snack bar's [Material.shape].
  ///
259 260 261 262 263 264
  /// If this property is null then [SnackBarThemeData.shape] of
  /// [ThemeData.snackBarTheme] is used. If that's null then the shape will
  /// depend on the [SnackBarBehavior]. For [SnackBarBehavior.fixed], no
  /// overriding shape is specified, so the [SnackBar] is rectangular. For
  /// [SnackBarBehavior.floating], it uses a [RoundedRectangleBorder] with a
  /// circular corner radius of 4.0.
265 266 267 268 269 270 271 272
  final ShapeBorder shape;

  /// This defines the behavior and location of the snack bar.
  ///
  /// Defines where a [SnackBar] should appear within a [Scaffold] and how its
  /// location should be adjusted when the scaffold also includes a
  /// [FloatingActionButton] or a [BottomNavigationBar]
  ///
273 274 275
  /// If this property is null, then [SnackBarThemeData.behavior] of
  /// [ThemeData.snackBarTheme] is used. If that is null, then the default is
  /// [SnackBarBehavior.fixed].
276 277
  final SnackBarBehavior behavior;

278 279 280 281
  /// (optional) An action that the user can take based on the snack bar.
  ///
  /// For example, the snack bar might let the user undo the operation that
  /// prompted the snackbar. Snack bars can have at most one action.
282 283
  ///
  /// The action should not be "dismiss" or "cancel".
284
  final SnackBarAction action;
285 286

  /// The amount of time the snack bar should be displayed.
287
  ///
jslavitz's avatar
jslavitz committed
288
  /// Defaults to 4.0s.
289 290 291
  ///
  /// See also:
  ///
292
  ///  * [ScaffoldState.removeCurrentSnackBar], which abruptly hides the
293 294
  ///    currently displayed snack bar, if any, and allows the next to be
  ///    displayed.
jslavitz's avatar
jslavitz committed
295
  ///  * <https://material.io/design/components/snackbars.html>
Hixie's avatar
Hixie committed
296
  final Duration duration;
297 298

  /// The animation driving the entrance and exit of the snack bar.
299
  final Animation<double> animation;
300

301 302 303
  /// Called the first time that the snackbar is visible within a [Scaffold].
  final VoidCallback onVisible;

304
  // API for Scaffold.showSnackBar():
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324

  /// Creates an animation controller useful for driving a snack bar's entrance and exit animation.
  static AnimationController createAnimationController({ @required TickerProvider vsync }) {
    return AnimationController(
      duration: _snackBarTransitionDuration,
      debugLabel: 'SnackBar',
      vsync: vsync,
    );
  }

  /// Creates a copy of this snack bar but with the animation replaced with the given animation.
  ///
  /// If the original snack bar lacks a key, the newly created snack bar will
  /// use the given fallback key.
  SnackBar withAnimation(Animation<double> newAnimation, { Key fallbackKey }) {
    return SnackBar(
      key: key ?? fallbackKey,
      content: content,
      backgroundColor: backgroundColor,
      elevation: elevation,
325 326 327
      margin: margin,
      padding: padding,
      width: width,
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379
      shape: shape,
      behavior: behavior,
      action: action,
      duration: duration,
      animation: newAnimation,
      onVisible: onVisible,
    );
  }

  @override
  State<SnackBar> createState() => _SnackBarState();
}


class _SnackBarState extends State<SnackBar> {
  bool _wasVisible = false;

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

  @override
  void didUpdateWidget(SnackBar oldWidget) {
    if (widget.animation != oldWidget.animation) {
      oldWidget.animation.removeStatusListener(_onAnimationStatusChanged);
      widget.animation.addStatusListener(_onAnimationStatusChanged);
    }
    super.didUpdateWidget(oldWidget);
  }

  @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;
    }
  }

380
  @override
381
  Widget build(BuildContext context) {
382
    final MediaQueryData mediaQueryData = MediaQuery.of(context);
383
    assert(widget.animation != null);
384
    final ThemeData theme = Theme.of(context);
385
    final ColorScheme colorScheme = theme.colorScheme;
386
    final SnackBarThemeData snackBarTheme = theme.snackBarTheme;
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
    final bool isThemeDark = theme.brightness == Brightness.dark;

    // SnackBar uses a theme that is the opposite brightness from
    // the surrounding theme.
    final Brightness brightness = isThemeDark ? Brightness.light : Brightness.dark;
    final Color themeBackgroundColor = isThemeDark
      ? colorScheme.onSurface
      : Color.alphaBlend(colorScheme.onSurface.withOpacity(0.80), colorScheme.surface);
    final ThemeData inverseTheme = ThemeData(
      brightness: brightness,
      backgroundColor: themeBackgroundColor,
      colorScheme: ColorScheme(
        primary: colorScheme.onPrimary,
        primaryVariant: colorScheme.onPrimary,
        // For the button color, the spec says it should be primaryVariant, but for
        // backward compatibility on light themes we are leaving it as secondary.
        secondary: isThemeDark ? colorScheme.primaryVariant : colorScheme.secondary,
        secondaryVariant: colorScheme.onSecondary,
        surface: colorScheme.onSurface,
        background: themeBackgroundColor,
        error: colorScheme.onError,
        onPrimary: colorScheme.primary,
        onSecondary: colorScheme.secondary,
        onSurface: colorScheme.surface,
        onBackground: colorScheme.background,
        onError: colorScheme.error,
        brightness: brightness,
      ),
415
      snackBarTheme: snackBarTheme,
416
    );
417

418
    final TextStyle contentTextStyle = snackBarTheme.contentTextStyle ?? inverseTheme.textTheme.subtitle1;
419
    final SnackBarBehavior snackBarBehavior = widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed;
420
    final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating;
421 422 423
    final double horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0;
    final EdgeInsetsGeometry padding = widget.padding
      ?? EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.action != null ? 0 : horizontalPadding);
424

425 426
    final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarHeightCurve);
    final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarFadeInCurve);
427
    final CurvedAnimation fadeOutAnimation = CurvedAnimation(
428
      parent: widget.animation,
429 430 431 432
      curve: _snackBarFadeOutCurve,
      reverseCurve: const Threshold(0.0),
    );

433 434
    Widget snackBar = Padding(
      padding: padding,
435
      child: Row(
436
        crossAxisAlignment: CrossAxisAlignment.center,
437 438 439 440 441 442
        children: <Widget>[
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding),
              child: DefaultTextStyle(
                style: contentTextStyle,
443
                child: widget.content,
444 445 446
              ),
            ),
          ),
447
          if (widget.action != null)
448 449 450
            ButtonTheme(
              textTheme: ButtonTextTheme.accent,
              minWidth: 64.0,
451
              padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
452
              child: widget.action,
453
            ),
454
        ],
455 456
      ),
    );
457

458 459 460 461 462 463 464
    if (!isFloatingSnackBar) {
      snackBar = SafeArea(
        top: false,
        child: snackBar,
      );
    }

465 466 467
    final double elevation = widget.elevation ?? snackBarTheme.elevation ?? 6.0;
    final Color backgroundColor = widget.backgroundColor ?? snackBarTheme.backgroundColor ?? inverseTheme.backgroundColor;
    final ShapeBorder shape = widget.shape
468 469 470 471 472 473 474 475
      ?? snackBarTheme.shape
      ?? (isFloatingSnackBar ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)) : null);

    snackBar = Material(
      shape: shape,
      elevation: elevation,
      color: backgroundColor,
      child: Theme(
476
        data: inverseTheme,
477 478 479 480 481 482 483 484 485 486
        child: mediaQueryData.accessibleNavigation
            ? snackBar
            : FadeTransition(
                opacity: fadeOutAnimation,
                child: snackBar,
              ),
      ),
    );

    if (isFloatingSnackBar) {
487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510
      const double topMargin = 5.0;
      const double bottomMargin = 10.0;
      // If width is provided, do not include horizontal margins.
      if (widget.width != null) {
        snackBar = Container(
          margin: const EdgeInsets.only(top: topMargin, bottom: bottomMargin),
          width: widget.width,
          child: snackBar,
        );
      } else {
        const double horizontalMargin = 15.0;
        snackBar = Padding(
          padding: widget.margin ?? const EdgeInsets.fromLTRB(
            horizontalMargin,
            topMargin,
            horizontalMargin,
            bottomMargin,
          ),
          child: snackBar,
        );
      }
      snackBar = SafeArea(
        top: false,
        bottom: false,
511 512 513 514 515
        child: snackBar,
      );
    }

    snackBar = Semantics(
516 517 518
      container: true,
      liveRegion: true,
      onDismiss: () {
519
        Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss);
520
      },
521
      child: Dismissible(
522 523 524 525
        key: const Key('dismissible'),
        direction: DismissDirection.down,
        resizeDuration: null,
        onDismissed: (DismissDirection direction) {
526
          Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
527
        },
528
        child: snackBar,
529 530
      ),
    );
531 532 533 534 535 536 537 538 539 540 541

    Widget snackBarTransition;
    if (mediaQueryData.accessibleNavigation) {
      snackBarTransition = snackBar;
    } else if (isFloatingSnackBar) {
      snackBarTransition = FadeTransition(
        opacity: fadeInAnimation,
        child: snackBar,
      );
    } else {
      snackBarTransition = AnimatedBuilder(
542
        animation: heightAnimation,
543
        builder: (BuildContext context, Widget child) {
544
          return Align(
545
            alignment: AlignmentDirectional.topStart,
546
            heightFactor: heightAnimation.value,
547
            child: child,
548
          );
549
        },
550 551 552
        child: snackBar,
      );
    }
553 554

    return ClipRect(child: snackBarTransition);
555
  }
556
}