snack_bar.dart 14.6 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 StatelessWidget {
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 180
  }) : assert(elevation == null || elevation >= 0.0),
       assert(content != null),
181
       assert(duration != null),
182
       super(key: key);
183

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

189 190 191 192
  /// 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.
193 194
  final Color backgroundColor;

195 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
  /// 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;

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

  /// The amount of time the snack bar should be displayed.
234
  ///
jslavitz's avatar
jslavitz committed
235
  /// Defaults to 4.0s.
236 237 238 239 240 241
  ///
  /// 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
242
  ///  * <https://material.io/design/components/snackbars.html>
Hixie's avatar
Hixie committed
243
  final Duration duration;
244 245

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

248
  @override
249
  Widget build(BuildContext context) {
250
    final MediaQueryData mediaQueryData = MediaQuery.of(context);
251
    assert(animation != null);
252
    final ThemeData theme = Theme.of(context);
253
    final ColorScheme colorScheme = theme.colorScheme;
254
    final SnackBarThemeData snackBarTheme = theme.snackBarTheme;
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
    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,
      ),
283
      snackBarTheme: snackBarTheme,
284
    );
285 286

    final TextStyle contentTextStyle = snackBarTheme.contentTextStyle ?? inverseTheme.textTheme.subhead;
287 288 289 290
    final SnackBarBehavior snackBarBehavior = behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed;
    final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating;
    final double snackBarPadding = isFloatingSnackBar ? 16.0 : 24.0;

291
    final List<Widget> children = <Widget>[
292
      SizedBox(width: snackBarPadding),
293 294
      Expanded(
        child: Container(
295
          padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding),
296
          child: DefaultTextStyle(
297
            style: contentTextStyle,
298
            child: content,
299 300 301
          ),
        ),
      ),
302
    ];
303
    if (action != null) {
304
      children.add(ButtonTheme(
305
        textTheme: ButtonTextTheme.accent,
306 307
        minWidth: 64.0,
        padding: EdgeInsets.symmetric(horizontal: snackBarPadding),
308
        child: action,
309 310
      ));
    } else {
311
      children.add(SizedBox(width: snackBarPadding));
312
    }
313
    final CurvedAnimation heightAnimation = CurvedAnimation(parent: animation, curve: _snackBarHeightCurve);
314 315 316 317 318 319 320 321
    final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: animation, curve: _snackBarFadeInCurve);
    final CurvedAnimation fadeOutAnimation = CurvedAnimation(
      parent: animation,
      curve: _snackBarFadeOutCurve,
      reverseCurve: const Threshold(0.0),
    );

    Widget snackBar = SafeArea(
322
      top: false,
323
      bottom: !isFloatingSnackBar,
324
      child: Row(
325 326 327 328
        children: children,
        crossAxisAlignment: CrossAxisAlignment.center,
      ),
    );
329 330

    final double elevation = this.elevation ?? snackBarTheme.elevation ?? 6.0;
331
    final Color backgroundColor = this.backgroundColor ?? snackBarTheme.backgroundColor ?? inverseTheme.backgroundColor;
332 333 334 335 336 337 338 339 340
    final ShapeBorder shape = this.shape
      ?? snackBarTheme.shape
      ?? (isFloatingSnackBar ? RoundedRectangleBorder(borderRadius: BorderRadius.circular(4.0)) : null);

    snackBar = Material(
      shape: shape,
      elevation: elevation,
      color: backgroundColor,
      child: Theme(
341
        data: inverseTheme,
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358
        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(
359 360 361 362 363
      container: true,
      liveRegion: true,
      onDismiss: () {
        Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.dismiss);
      },
364
      child: Dismissible(
365 366 367 368 369 370
        key: const Key('dismissible'),
        direction: DismissDirection.down,
        resizeDuration: null,
        onDismissed: (DismissDirection direction) {
          Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
        },
371
        child: snackBar,
372 373
      ),
    );
374 375 376 377 378 379 380 381 382 383 384

    Widget snackBarTransition;
    if (mediaQueryData.accessibleNavigation) {
      snackBarTransition = snackBar;
    } else if (isFloatingSnackBar) {
      snackBarTransition = FadeTransition(
        opacity: fadeInAnimation,
        child: snackBar,
      );
    } else {
      snackBarTransition = AnimatedBuilder(
385
        animation: heightAnimation,
386
        builder: (BuildContext context, Widget child) {
387
          return Align(
388
            alignment: AlignmentDirectional.topStart,
389
            heightFactor: heightAnimation.value,
390
            child: child,
391
          );
392
        },
393 394 395 396 397
        child: snackBar,
      );
    }

    return ClipRect(child: snackBarTransition);
398
  }
399

Hixie's avatar
Hixie committed
400
  // API for Scaffold.addSnackBar():
401

402
  /// Creates an animation controller useful for driving a snack bar's entrance and exit animation.
403
  static AnimationController createAnimationController({ @required TickerProvider vsync }) {
404
    return AnimationController(
405
      duration: _snackBarTransitionDuration,
406 407
      debugLabel: 'SnackBar',
      vsync: vsync,
Hixie's avatar
Hixie committed
408 409
    );
  }
410

411 412 413 414
  /// 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.
415
  SnackBar withAnimation(Animation<double> newAnimation, { Key fallbackKey }) {
416
    return SnackBar(
417
      key: key ?? fallbackKey,
Hixie's avatar
Hixie committed
418
      content: content,
419
      backgroundColor: backgroundColor,
420 421 422
      elevation: elevation,
      shape: shape,
      behavior: behavior,
423
      action: action,
Hixie's avatar
Hixie committed
424
      duration: duration,
425
      animation: newAnimation,
Hixie's avatar
Hixie committed
426 427
    );
  }
428
}