snack_bar.dart 15.9 KB
Newer Older
1 2 3 4
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/rendering.dart';
6
import 'package:flutter/widgets.dart';
7

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

17
const double _singleLineVerticalPadding = 14.0;
Matt Perry's avatar
Matt Perry committed
18

Hixie's avatar
Hixie committed
19 20 21 22 23 24
// 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
// the multiline layout. See link above.

// TODO(ianh): Implement the Tablet version of snackbar if we're "on a tablet".

25 26
const Duration _snackBarTransitionDuration = Duration(milliseconds: 250);
const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000);
27
const Curve _snackBarHeightCurve = Curves.fastOutSlowIn;
28 29
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
30

31 32
/// Specify how a [SnackBar] was closed.
///
33 34 35 36
/// The [ScaffoldState.showSnackBar] function returns a
/// [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.
37 38 39 40 41
///
/// Example:
///
/// ```dart
/// Scaffold.of(context).showSnackBar(
42
///   SnackBar( ... )
43 44 45 46 47 48 49 50
/// ).closed.then((SnackBarClosedReason reason) {
///    ...
/// });
/// ```
enum SnackBarClosedReason {
  /// The snack bar was closed after the user tapped a [SnackBarAction].
  action,

51 52 53
  /// The snack bar was closed through a [SemanticAction.dismiss].
  dismiss,

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

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

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

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

68 69 70 71 72
/// 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.
///
73 74
/// Snack bar actions can only be pressed once. Subsequent presses are ignored.
///
75 76 77
/// See also:
///
///  * [SnackBar]
jslavitz's avatar
jslavitz committed
78
///  * <https://material.io/design/components/snackbars.html>
79
class SnackBarAction extends StatefulWidget {
80 81 82
  /// Creates an action for a [SnackBar].
  ///
  /// The [label] and [onPressed] arguments must be non-null.
83
  const SnackBarAction({
84
    Key key,
jslavitz's avatar
jslavitz committed
85 86
    this.textColor,
    this.disabledTextColor,
87
    @required this.label,
88
    @required this.onPressed,
89 90 91
  }) : assert(label != null),
       assert(onPressed != null),
       super(key: key);
92

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

  /// The button disabled label color. This color is shown after the
  /// [snackBarAction] is dismissed.
  final Color disabledTextColor;

100
  /// The button label.
101
  final String label;
102

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

109
  @override
110
  _SnackBarActionState createState() => _SnackBarActionState();
111 112 113 114 115 116 117 118 119 120 121
}

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

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

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

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

141 142 143
/// A lightweight message with an optional action which briefly displays at the
/// bottom of the screen.
///
144 145 146 147
/// To display a snack bar, call `Scaffold.of(context).showSnackBar()`, passing
/// an instance of [SnackBar] that describes the message.
///
/// To control how long the [SnackBar] remains visible, specify a [duration].
148
///
149 150 151
/// A SnackBar with an action will not time out when TalkBack or VoiceOver are
/// enabled. This is controlled by [AccessibilityFeatures.accessibleNavigation].
///
152
/// See also:
153
///
154 155 156 157 158 159 160
///  * [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.
///  * [SnackBarAction], which is used to specify an [action] button to show
///    on the snack bar.
161 162
///  * [SnackBarThemeData], to configure the default property values for
///    [SnackBar] widgets.
jslavitz's avatar
jslavitz committed
163
///  * <https://material.io/design/components/snackbars.html>
164
class SnackBar extends StatefulWidget {
165 166
  /// Creates a snack bar.
  ///
167 168
  /// The [content] argument must be non-null. The [elevation] must be null or
  /// non-negative.
169
  const SnackBar({
170
    Key key,
171
    @required this.content,
172
    this.backgroundColor,
173 174 175
    this.elevation,
    this.shape,
    this.behavior,
176
    this.action,
177
    this.duration = _snackBarDisplayDuration,
178
    this.animation,
179
    this.onVisible,
180 181
  }) : assert(elevation == null || elevation >= 0.0),
       assert(content != null),
182
       assert(duration != null),
183
       super(key: key);
184

185 186 187
  /// The primary content of the snack bar.
  ///
  /// Typically a [Text] widget.
188
  final Widget content;
189

190 191 192 193
  /// The Snackbar's background color. If not specified it will use
  /// [ThemeData.snackBarTheme.backgroundColor]. If that is not specified
  /// it will default to a dark variation of [ColorScheme.surface] for light
  /// themes, or [ColorScheme.onSurface] for dark themes.
194 195
  final Color backgroundColor;

196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
  /// 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].
  ///
  /// If this property is null, then [ThemeData.snackBarTheme.elevation] is
  /// used, if that is also null, the default value is 6.0.
  final double elevation;

  /// The shape of the snack bar's [Material].
  ///
  /// Defines the snack bar's [Material.shape].
  ///
  /// If this property is null then [ThemeData.snackBarTheme.shape] 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.
  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]
  ///
  /// If this property is null, then [ThemeData.snackBarTheme.behavior]
  /// is used. If that is null, then the default is [SnackBarBehavior.fixed].
  final SnackBarBehavior behavior;

226 227 228 229
  /// (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.
230 231
  ///
  /// The action should not be "dismiss" or "cancel".
232
  final SnackBarAction action;
233 234

  /// The amount of time the snack bar should be displayed.
235
  ///
jslavitz's avatar
jslavitz committed
236
  /// Defaults to 4.0s.
237 238 239 240 241 242
  ///
  /// See also:
  ///
  ///  * [ScaffoldState.removeCurrentSnackBar], which abruptly hides the
  ///    currently displayed snack bar, if any, and allows the next to be
  ///    displayed.
jslavitz's avatar
jslavitz committed
243
  ///  * <https://material.io/design/components/snackbars.html>
Hixie's avatar
Hixie committed
244
  final Duration duration;
245 246

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

249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
  /// Called the first time that the snackbar is visible within a [Scaffold].
  final VoidCallback onVisible;

  // API for Scaffold.showSnackBar():

  /// 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,
      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;
    }
  }

325
  @override
326
  Widget build(BuildContext context) {
327
    final MediaQueryData mediaQueryData = MediaQuery.of(context);
328
    assert(widget.animation != null);
329
    final ThemeData theme = Theme.of(context);
330
    final ColorScheme colorScheme = theme.colorScheme;
331
    final SnackBarThemeData snackBarTheme = theme.snackBarTheme;
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
    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,
      ),
360
      snackBarTheme: snackBarTheme,
361
    );
362 363

    final TextStyle contentTextStyle = snackBarTheme.contentTextStyle ?? inverseTheme.textTheme.subhead;
364
    final SnackBarBehavior snackBarBehavior = widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed;
365 366 367
    final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating;
    final double snackBarPadding = isFloatingSnackBar ? 16.0 : 24.0;

368 369
    final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarHeightCurve);
    final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarFadeInCurve);
370
    final CurvedAnimation fadeOutAnimation = CurvedAnimation(
371
      parent: widget.animation,
372 373 374 375 376
      curve: _snackBarFadeOutCurve,
      reverseCurve: const Threshold(0.0),
    );

    Widget snackBar = SafeArea(
377
      top: false,
378
      bottom: !isFloatingSnackBar,
379
      child: Row(
380
        crossAxisAlignment: CrossAxisAlignment.center,
381 382 383 384 385 386 387
        children: <Widget>[
          SizedBox(width: snackBarPadding),
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding),
              child: DefaultTextStyle(
                style: contentTextStyle,
388
                child: widget.content,
389 390 391
              ),
            ),
          ),
392
          if (widget.action != null)
393 394 395 396
            ButtonTheme(
              textTheme: ButtonTextTheme.accent,
              minWidth: 64.0,
              padding: EdgeInsets.symmetric(horizontal: snackBarPadding),
397
              child: widget.action,
398 399 400 401
            )
          else
            SizedBox(width: snackBarPadding),
        ],
402 403
      ),
    );
404

405 406 407
    final double elevation = widget.elevation ?? snackBarTheme.elevation ?? 6.0;
    final Color backgroundColor = widget.backgroundColor ?? snackBarTheme.backgroundColor ?? inverseTheme.backgroundColor;
    final ShapeBorder shape = widget.shape
408 409 410 411 412 413 414 415
      ?? snackBarTheme.shape
      ?? (isFloatingSnackBar ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)) : null);

    snackBar = Material(
      shape: shape,
      elevation: elevation,
      color: backgroundColor,
      child: Theme(
416
        data: inverseTheme,
417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433
        child: mediaQueryData.accessibleNavigation
            ? snackBar
            : FadeTransition(
                opacity: fadeOutAnimation,
                child: snackBar,
              ),
      ),
    );

    if (isFloatingSnackBar) {
      snackBar = Padding(
        padding: const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0),
        child: snackBar,
      );
    }

    snackBar = Semantics(
434 435 436 437 438
      container: true,
      liveRegion: true,
      onDismiss: () {
        Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss);
      },
439
      child: Dismissible(
440 441 442 443 444 445
        key: const Key('dismissible'),
        direction: DismissDirection.down,
        resizeDuration: null,
        onDismissed: (DismissDirection direction) {
          Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
        },
446
        child: snackBar,
447 448
      ),
    );
449 450 451 452 453 454 455 456 457 458 459

    Widget snackBarTransition;
    if (mediaQueryData.accessibleNavigation) {
      snackBarTransition = snackBar;
    } else if (isFloatingSnackBar) {
      snackBarTransition = FadeTransition(
        opacity: fadeInAnimation,
        child: snackBar,
      );
    } else {
      snackBarTransition = AnimatedBuilder(
460
        animation: heightAnimation,
461
        builder: (BuildContext context, Widget child) {
462
          return Align(
463
            alignment: AlignmentDirectional.topStart,
464
            heightFactor: heightAnimation.value,
465
            child: child,
466
          );
467
        },
468 469 470 471 472
        child: snackBar,
      );
    }

    return ClipRect(child: snackBarTransition);
473
  }
474
}