From d4e4726ac262b9f78b002b696f915f552fed3fc8 Mon Sep 17 00:00:00 2001 From: rami-a <2364772+rami-a@users.noreply.github.com> Date: Wed, 24 Apr 2019 17:22:30 -0400 Subject: [PATCH] Update SnackBar to allow for support of the new style from Material spec (#31275) This PR introduces a number of changes and improvements to snack bars. This includes the ability to specify: floating style of snack bars that adhere to the updated Material spec elevation and shape on the SnackBar itself instead of relying on fixed values a snackBarTheme as part of ThemeData which allows you to customize all of the above on an app-wide level. This PR is includes the changes from #21484 as well as additional fixes and modifications. Thanks to @NikoYuwono for providing these changes and getting this off the ground! --- .../lib/demo/material/snack_bar_demo.dart | 7 + packages/flutter/lib/material.dart | 1 + .../flutter/lib/src/material/scaffold.dart | 33 +- .../flutter/lib/src/material/snack_bar.dart | 157 +++++++--- .../lib/src/material/snack_bar_theme.dart | 173 +++++++++++ .../flutter/lib/src/material/theme_data.dart | 19 +- .../flutter/test/material/snack_bar_test.dart | 237 ++++++++++++-- .../test/material/snack_bar_theme_test.dart | 290 ++++++++++++++++++ 8 files changed, 853 insertions(+), 64 deletions(-) create mode 100644 packages/flutter/lib/src/material/snack_bar_theme.dart create mode 100644 packages/flutter/test/material/snack_bar_theme_test.dart diff --git a/examples/flutter_gallery/lib/demo/material/snack_bar_demo.dart b/examples/flutter_gallery/lib/demo/material/snack_bar_demo.dart index 5dbe97671f8..011203d6f1f 100644 --- a/examples/flutter_gallery/lib/demo/material/snack_bar_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/snack_bar_demo.dart @@ -83,6 +83,13 @@ class _SnackBarDemoState extends State { // can refer to the Scaffold with Scaffold.of(). builder: buildBody ), + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.add), + tooltip: 'Create', + onPressed: () { + print('Floating Action Button was pressed'); + } + ), ); } } diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 4bd92d02718..0d710a25f09 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -94,6 +94,7 @@ export 'src/material/shadows.dart'; export 'src/material/slider.dart'; export 'src/material/slider_theme.dart'; export 'src/material/snack_bar.dart'; +export 'src/material/snack_bar_theme.dart'; export 'src/material/stepper.dart'; export 'src/material/switch.dart'; export 'src/material/switch_list_tile.dart'; diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 33c8757c0b2..780dc25bfc3 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -23,7 +23,9 @@ import 'floating_action_button.dart'; import 'floating_action_button_location.dart'; import 'material.dart'; import 'snack_bar.dart'; +import 'snack_bar_theme.dart'; import 'theme.dart'; +import 'theme_data.dart'; // Examples can assume: // TabController tabController; @@ -355,6 +357,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { @required this.currentFloatingActionButtonLocation, @required this.floatingActionButtonMoveAnimationProgress, @required this.floatingActionButtonMotionAnimator, + @required this.isSnackBarFloating, @required this.extendBody, }) : assert(minInsets != null), assert(textDirection != null), @@ -373,6 +376,8 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { final double floatingActionButtonMoveAnimationProgress; final FloatingActionButtonAnimator floatingActionButtonMotionAnimator; + final bool isSnackBarFloating; + @override void performLayout(Size size) { final BoxConstraints looseConstraints = BoxConstraints.loose(size); @@ -447,6 +452,12 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { Size bottomSheetSize = Size.zero; Size snackBarSize = Size.zero; + // Set the size of the SnackBar early if the behavior is fixed so + // the FAB can be positioned correctly. + if (hasChild(_ScaffoldSlot.snackBar) && !isSnackBarFloating) { + snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); + } + if (hasChild(_ScaffoldSlot.bottomSheet)) { final BoxConstraints bottomSheetConstraints = BoxConstraints( maxWidth: fullWidthConstraints.maxWidth, @@ -456,11 +467,6 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { positionChild(_ScaffoldSlot.bottomSheet, Offset((size.width - bottomSheetSize.width) / 2.0, contentBottom - bottomSheetSize.height)); } - if (hasChild(_ScaffoldSlot.snackBar)) { - snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); - positionChild(_ScaffoldSlot.snackBar, Offset(0.0, contentBottom - snackBarSize.height)); - } - Rect floatingActionButtonRect; if (hasChild(_ScaffoldSlot.floatingActionButton)) { final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints); @@ -488,6 +494,16 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { floatingActionButtonRect = fabOffset & fabSize; } + if (hasChild(_ScaffoldSlot.snackBar)) { + if (snackBarSize == Size.zero) { + snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); + } + final double snackBarYOffsetBase = floatingActionButtonRect != null && isSnackBarFloating + ? floatingActionButtonRect.top + : contentBottom; + positionChild(_ScaffoldSlot.snackBar, Offset(0.0, snackBarYOffsetBase - snackBarSize.height)); + } + if (hasChild(_ScaffoldSlot.statusBar)) { layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top)); positionChild(_ScaffoldSlot.statusBar, Offset.zero); @@ -1828,7 +1844,13 @@ class ScaffoldState extends State with TickerProviderStateMixin { ); } + bool isSnackBarFloating = false; if (_snackBars.isNotEmpty) { + final SnackBarBehavior snackBarBehavior = _snackBars.first._widget.behavior + ?? themeData.snackBarTheme.behavior + ?? SnackBarBehavior.fixed; + isSnackBarFloating = snackBarBehavior == SnackBarBehavior.floating; + _addIfNonNull( children, _snackBars.first._widget, @@ -1973,6 +1995,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { geometryNotifier: _geometryNotifier, previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation, textDirection: textDirection, + isSnackBarFloating: isSnackBarFloating, ), ); }), diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index 55c88ff698c..1e09d493119 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -9,12 +9,12 @@ import 'button_theme.dart'; import 'flat_button.dart'; import 'material.dart'; import 'scaffold.dart'; +import 'snack_bar_theme.dart'; import 'theme.dart'; import 'theme_data.dart'; -const double _kSnackBarPadding = 24.0; -const double _kSingleLineVerticalPadding = 14.0; -const Color _kSnackBackground = Color(0xFF323232); +const double _singleLineVerticalPadding = 14.0; +const Color _snackBarBackgroundColor = Color(0xFF323232); // 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 @@ -22,10 +22,11 @@ const Color _kSnackBackground = Color(0xFF323232); // TODO(ianh): Implement the Tablet version of snackbar if we're "on a tablet". -const Duration _kSnackBarTransitionDuration = Duration(milliseconds: 250); -const Duration _kSnackBarDisplayDuration = Duration(milliseconds: 4000); +const Duration _snackBarTransitionDuration = Duration(milliseconds: 250); +const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000); const Curve _snackBarHeightCurve = Curves.fastOutSlowIn; -const Curve _snackBarFadeCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); +const Curve _snackBarFadeInCurve = Interval(0.45, 1.0, curve: Curves.fastOutSlowIn); +const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); /// Specify how a [SnackBar] was closed. /// @@ -124,11 +125,15 @@ class _SnackBarActionState extends State { @override Widget build(BuildContext context) { + final SnackBarThemeData snackBarTheme = Theme.of(context).snackBarTheme; + final Color textColor = widget.textColor ?? snackBarTheme.actionTextColor; + final Color disabledTextColor = widget.disabledTextColor ?? snackBarTheme.disabledActionTextColor; + return FlatButton( onPressed: _haveTriggeredAction ? null : _handlePressed, child: Text(widget.label), - textColor: widget.textColor, - disabledTextColor: widget.disabledTextColor, + textColor: textColor, + disabledTextColor: disabledTextColor, ); } } @@ -157,15 +162,20 @@ class _SnackBarActionState extends State { class SnackBar extends StatelessWidget { /// Creates a snack bar. /// - /// The [content] argument must be non-null. + /// The [content] argument must be non-null. The [elevation] must be null or + /// non-negative. const SnackBar({ Key key, @required this.content, this.backgroundColor, + this.elevation, + this.shape, + this.behavior, this.action, - this.duration = _kSnackBarDisplayDuration, + this.duration = _snackBarDisplayDuration, this.animation, - }) : assert(content != null), + }) : assert(elevation == null || elevation >= 0.0), + assert(content != null), assert(duration != null), super(key: key); @@ -177,6 +187,36 @@ class SnackBar extends StatelessWidget { /// The Snackbar's background color. By default the color is dark grey. final Color backgroundColor; + /// 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; + /// (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 @@ -205,16 +245,23 @@ class SnackBar extends StatelessWidget { final MediaQueryData mediaQueryData = MediaQuery.of(context); assert(animation != null); final ThemeData theme = Theme.of(context); + final SnackBarThemeData snackBarTheme = theme.snackBarTheme; + // TODO(rami-a): Use a light theme if the app has a dark theme, https://github.com/flutter/flutter/issues/31418 final ThemeData darkTheme = ThemeData( brightness: Brightness.dark, accentColor: theme.accentColor, accentColorBrightness: theme.accentColorBrightness, + snackBarTheme: snackBarTheme, ); + final SnackBarBehavior snackBarBehavior = behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed; + final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating; + final double snackBarPadding = isFloatingSnackBar ? 16.0 : 24.0; + final List children = [ - const SizedBox(width: _kSnackBarPadding), + SizedBox(width: snackBarPadding), Expanded( child: Container( - padding: const EdgeInsets.symmetric(vertical: _kSingleLineVerticalPadding), + padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding), child: DefaultTextStyle( style: darkTheme.textTheme.subhead, child: content, @@ -224,23 +271,59 @@ class SnackBar extends StatelessWidget { ]; if (action != null) { children.add(ButtonTheme.bar( - padding: const EdgeInsets.symmetric(horizontal: _kSnackBarPadding), + padding: EdgeInsets.symmetric(horizontal: snackBarPadding), textTheme: ButtonTextTheme.accent, child: action, )); } else { - children.add(const SizedBox(width: _kSnackBarPadding)); + children.add(SizedBox(width: snackBarPadding)); } final CurvedAnimation heightAnimation = CurvedAnimation(parent: animation, curve: _snackBarHeightCurve); - final CurvedAnimation fadeAnimation = CurvedAnimation(parent: animation, curve: _snackBarFadeCurve, reverseCurve: const Threshold(0.0)); - Widget snackbar = SafeArea( + final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: animation, curve: _snackBarFadeInCurve); + final CurvedAnimation fadeOutAnimation = CurvedAnimation( + parent: animation, + curve: _snackBarFadeOutCurve, + reverseCurve: const Threshold(0.0), + ); + + Widget snackBar = SafeArea( top: false, + bottom: !isFloatingSnackBar, child: Row( children: children, crossAxisAlignment: CrossAxisAlignment.center, ), ); - snackbar = Semantics( + + final double elevation = this.elevation ?? snackBarTheme.elevation ?? 6.0; + final Color backgroundColor = this.backgroundColor ?? snackBarTheme.backgroundColor ?? _snackBarBackgroundColor; + 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( + data: darkTheme, + 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( container: true, liveRegion: true, onDismiss: () { @@ -253,21 +336,20 @@ class SnackBar extends StatelessWidget { onDismissed: (DismissDirection direction) { Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe); }, - child: Material( - elevation: 6.0, - color: backgroundColor ?? _kSnackBackground, - child: Theme( - data: darkTheme, - child: mediaQueryData.accessibleNavigation ? snackbar : FadeTransition( - opacity: fadeAnimation, - child: snackbar, - ), - ), - ), + child: snackBar, ), ); - return ClipRect( - child: mediaQueryData.accessibleNavigation ? snackbar : AnimatedBuilder( + + Widget snackBarTransition; + if (mediaQueryData.accessibleNavigation) { + snackBarTransition = snackBar; + } else if (isFloatingSnackBar) { + snackBarTransition = FadeTransition( + opacity: fadeInAnimation, + child: snackBar, + ); + } else { + snackBarTransition = AnimatedBuilder( animation: heightAnimation, builder: (BuildContext context, Widget child) { return Align( @@ -276,9 +358,11 @@ class SnackBar extends StatelessWidget { child: child, ); }, - child: snackbar, - ), - ); + child: snackBar, + ); + } + + return ClipRect(child: snackBarTransition); } // API for Scaffold.addSnackBar(): @@ -286,7 +370,7 @@ class SnackBar extends StatelessWidget { /// Creates an animation controller useful for driving a snack bar's entrance and exit animation. static AnimationController createAnimationController({ @required TickerProvider vsync }) { return AnimationController( - duration: _kSnackBarTransitionDuration, + duration: _snackBarTransitionDuration, debugLabel: 'SnackBar', vsync: vsync, ); @@ -301,6 +385,9 @@ class SnackBar extends StatelessWidget { key: key ?? fallbackKey, content: content, backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + behavior: behavior, action: action, duration: duration, animation: newAnimation, diff --git a/packages/flutter/lib/src/material/snack_bar_theme.dart b/packages/flutter/lib/src/material/snack_bar_theme.dart new file mode 100644 index 00000000000..01e3fce31de --- /dev/null +++ b/packages/flutter/lib/src/material/snack_bar_theme.dart @@ -0,0 +1,173 @@ +// Copyright 2019 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. + +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// 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]. +enum SnackBarBehavior { + /// Fixes the [SnackBar] at the bottom of the [Scaffold]. + /// + /// The exception is that the [SnackBar] will be shown above a + /// [BottomNavigationBar]. Additionally, the [SnackBar] will cause other + /// non-fixed widgets inside [Scaffold] to be pushed above (for example, the + /// [FloatingActionButton]). + fixed, + + /// This behavior will cause [SnackBar] to be shown above other widgets in the + /// [Scaffold]. This includes being displayed above a [BottomNavigationBar] + /// and a [FloatingActionButton]. + /// + /// See for more details. + floating, +} + +/// Customizes default property values for [SnackBar] widgets. +/// +/// Descendant widgets obtain the current [SnackBarThemeData] object using +/// `Theme.of(context).snackBarTheme`. Instances of [SnackBarThemeData] can be +/// customized with [SnackBarThemeData.copyWith]. +/// +/// Typically a [SnackBarThemeData] is specified as part of the overall [Theme] +/// with [ThemeData.snackBarTheme]. The default for [ThemeData.snackBarTheme] +/// provides all `null` properties. +/// +/// All [SnackBarThemeData] properties are `null` by default. When null, the +/// [SnackBar] will provide its own defaults. +/// +/// See also: +/// +/// * [ThemeData], which describes the overall theme information for the +/// application. +class SnackBarThemeData extends Diagnosticable { + + /// Creates a theme that can be used for [ThemeData.snackBarTheme]. + /// + /// The [elevation] must be null or non-negative. + const SnackBarThemeData({ + this.backgroundColor, + this.actionTextColor, + this.disabledActionTextColor, + this.elevation, + this.shape, + this.behavior, + }) : assert(elevation == null || elevation >= 0.0); + + /// Default value for [SnackBar.backgroundColor]. + /// + /// If null, [SnackBar] defaults to dark grey: `Color(0xFF323232)`. + final Color backgroundColor; + + /// Default value for [SnackBarAction.textColor]. + /// + /// If null, [SnackBarAction] defaults to [ThemeData.colorScheme.secondaryColor]. + final Color actionTextColor; + + /// Default value for [SnackBarAction.disabledTextColor]. + /// + /// If null, [SnackBarAction] defaults to [ColorScheme.onSurface] with its + /// opacity set to 0.30 if the [Theme]'s brightness is [Brightness.dark], 0.38 + /// otherwise. + final Color disabledActionTextColor; + + /// Default value for [SnackBar.elevation]. + /// + /// If null, [SnackBar] uses a default of 6.0. + final double elevation; + + /// Default value for [SnackBar.shape]. + /// + /// If null, [SnackBar] provides different defaults depending 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; + + /// Default value for [SnackBar.behavior]. + /// + /// If null, [SnackBar] will default to [SnackBarBehavior.fixed]. + final SnackBarBehavior behavior; + + /// Creates a copy of this object with the given fields replaced with the + /// new values. + SnackBarThemeData copyWith({ + Color backgroundColor, + Color actionTextColor, + Color disabledActionTextColor, + double elevation, + ShapeBorder shape, + SnackBarBehavior behavior, + }) { + return SnackBarThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + actionTextColor: actionTextColor ?? this.actionTextColor, + disabledActionTextColor: disabledActionTextColor ?? this.disabledActionTextColor, + elevation: elevation ?? this.elevation, + shape: shape ?? this.shape, + behavior: behavior ?? this.behavior, + ); + } + + /// Linearly interpolate between two SnackBar Themes. + /// + /// The argument `t` must not be null. + /// + /// {@macro dart.ui.shadow.lerp} + static SnackBarThemeData lerp(SnackBarThemeData a, SnackBarThemeData b, double t) { + assert(t != null); + return SnackBarThemeData( + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + actionTextColor: Color.lerp(a?.actionTextColor, b?.actionTextColor, t), + disabledActionTextColor: Color.lerp(a?.disabledActionTextColor, b?.disabledActionTextColor, t), + elevation: lerpDouble(a?.elevation, b?.elevation, t), + shape: ShapeBorder.lerp(a?.shape, b?.shape, t), + behavior: t < 0.5 ? a.behavior : b.behavior, + ); + } + + @override + int get hashCode { + return hashValues( + backgroundColor, + actionTextColor, + disabledActionTextColor, + elevation, + shape, + behavior, + ); + } + + @override + bool operator ==(dynamic other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final SnackBarThemeData typedOther = other; + return typedOther.backgroundColor == backgroundColor + && typedOther.actionTextColor == actionTextColor + && typedOther.disabledActionTextColor == disabledActionTextColor + && typedOther.elevation == elevation + && typedOther.shape == shape + && typedOther.behavior == behavior; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('backgroundColor', backgroundColor, defaultValue: null)); + properties.add(DiagnosticsProperty('actionTextColor', actionTextColor, defaultValue: null)); + properties.add(DiagnosticsProperty('disabledActionTextColor', disabledActionTextColor, defaultValue: null)); + properties.add(DiagnosticsProperty('elevation', elevation, defaultValue: null)); + properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); + properties.add(DiagnosticsProperty('behavior', behavior, defaultValue: null)); + } +} diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index e124d316b9a..c641b45f2e5 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -23,6 +23,7 @@ import 'ink_well.dart' show InteractiveInkFeatureFactory; import 'input_decorator.dart'; import 'page_transitions_theme.dart'; import 'slider_theme.dart'; +import 'snack_bar_theme.dart'; import 'tab_bar_theme.dart'; import 'text_theme.dart'; import 'typography.dart'; @@ -163,6 +164,7 @@ class ThemeData extends Diagnosticable { FloatingActionButtonThemeData floatingActionButtonTheme, Typography typography, CupertinoThemeData cupertinoOverrideTheme, + SnackBarThemeData snackBarTheme, }) { brightness ??= Brightness.light; final bool isDark = brightness == Brightness.dark; @@ -256,6 +258,7 @@ class ThemeData extends Diagnosticable { dialogTheme ??= const DialogTheme(); floatingActionButtonTheme ??= const FloatingActionButtonThemeData(); cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); + snackBarTheme ??= const SnackBarThemeData(); return ThemeData.raw( brightness: brightness, @@ -309,6 +312,7 @@ class ThemeData extends Diagnosticable { floatingActionButtonTheme: floatingActionButtonTheme, typography: typography, cupertinoOverrideTheme: cupertinoOverrideTheme, + snackBarTheme: snackBarTheme, ); } @@ -374,6 +378,7 @@ class ThemeData extends Diagnosticable { @required this.floatingActionButtonTheme, @required this.typography, @required this.cupertinoOverrideTheme, + @required this.snackBarTheme, }) : assert(brightness != null), assert(primaryColor != null), assert(primaryColorBrightness != null), @@ -422,7 +427,8 @@ class ThemeData extends Diagnosticable { assert(colorScheme != null), assert(dialogTheme != null), assert(floatingActionButtonTheme != null), - assert(typography != null); + assert(typography != null), + assert(snackBarTheme != null); // Warning: make sure these properties are in the exact same order as in // hashValues() and in the raw constructor and in the order of fields in @@ -660,6 +666,9 @@ class ThemeData extends Diagnosticable { /// that is possible without significant backwards compatibility breaks. final ColorScheme colorScheme; + /// A theme for customizing colors, shape, elevation, and behavior of a [SnackBar]. + final SnackBarThemeData snackBarTheme; + /// A theme for customizing the shape of a dialog. final DialogTheme dialogTheme; @@ -736,6 +745,7 @@ class ThemeData extends Diagnosticable { FloatingActionButtonThemeData floatingActionButtonTheme, Typography typography, CupertinoThemeData cupertinoOverrideTheme, + SnackBarThemeData snackBarTheme, }) { cupertinoOverrideTheme = cupertinoOverrideTheme?.noDefault(); return ThemeData.raw( @@ -790,6 +800,7 @@ class ThemeData extends Diagnosticable { floatingActionButtonTheme: floatingActionButtonTheme ?? this.floatingActionButtonTheme, typography: typography ?? this.typography, cupertinoOverrideTheme: cupertinoOverrideTheme ?? this.cupertinoOverrideTheme, + snackBarTheme: snackBarTheme ?? this.snackBarTheme, ); } @@ -922,6 +933,7 @@ class ThemeData extends Diagnosticable { floatingActionButtonTheme: FloatingActionButtonThemeData.lerp(a.floatingActionButtonTheme, b.floatingActionButtonTheme, t), typography: Typography.lerp(a.typography, b.typography, t), cupertinoOverrideTheme: t < 0.5 ? a.cupertinoOverrideTheme : b.cupertinoOverrideTheme, + snackBarTheme: SnackBarThemeData.lerp(a.snackBarTheme, b.snackBarTheme, t), ); } @@ -983,7 +995,8 @@ class ThemeData extends Diagnosticable { (otherData.dialogTheme == dialogTheme) && (otherData.floatingActionButtonTheme == floatingActionButtonTheme) && (otherData.typography == typography) && - (otherData.cupertinoOverrideTheme == cupertinoOverrideTheme); + (otherData.cupertinoOverrideTheme == cupertinoOverrideTheme) && + (otherData.snackBarTheme == snackBarTheme); } @override @@ -1046,6 +1059,7 @@ class ThemeData extends Diagnosticable { floatingActionButtonTheme, typography, cupertinoOverrideTheme, + snackBarTheme, ), ), ); @@ -1103,6 +1117,7 @@ class ThemeData extends Diagnosticable { properties.add(DiagnosticsProperty('floatingActionButtonThemeData', floatingActionButtonTheme, defaultValue: defaultData.floatingActionButtonTheme)); properties.add(DiagnosticsProperty('typography', typography, defaultValue: defaultData.typography)); properties.add(DiagnosticsProperty('cupertinoOverrideTheme', cupertinoOverrideTheme, defaultValue: defaultData.cupertinoOverrideTheme)); + properties.add(DiagnosticsProperty('snackBarTheme', snackBarTheme, defaultValue: defaultData.snackBarTheme)); } } diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index b9e36779a4d..281257d4344 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -372,18 +372,14 @@ void main() { )); await tester.tap(find.text('X')); await tester.pump(); // start animation - await tester.pump(const Duration(milliseconds: 750)); + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. - final RenderBox textBox = tester.firstRenderObject(find.text('I am a snack bar.')); - final RenderBox actionTextBox = tester.firstRenderObject(find.text('ACTION')); - final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); - - final Offset textBottomLeft = textBox.localToGlobal(textBox.size.bottomLeft(Offset.zero)); - final Offset textBottomRight = textBox.localToGlobal(textBox.size.bottomRight(Offset.zero)); - final Offset actionTextBottomLeft = actionTextBox.localToGlobal(actionTextBox.size.bottomLeft(Offset.zero)); - final Offset actionTextBottomRight = actionTextBox.localToGlobal(actionTextBox.size.bottomRight(Offset.zero)); - final Offset snackBarBottomLeft = snackBarBox.localToGlobal(snackBarBox.size.bottomLeft(Offset.zero)); - final Offset snackBarBottomRight = snackBarBox.localToGlobal(snackBarBox.size.bottomRight(Offset.zero)); + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding expect(snackBarBottomLeft.dy - textBottomLeft.dy, 17.0 + 40.0); // margin + bottom padding @@ -429,18 +425,14 @@ void main() { )); await tester.tap(find.text('X')); await tester.pump(); // start animation - await tester.pump(const Duration(milliseconds: 750)); + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. - final RenderBox textBox = tester.firstRenderObject(find.text('I am a snack bar.')); - final RenderBox actionTextBox = tester.firstRenderObject(find.text('ACTION')); - final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); - - final Offset textBottomLeft = textBox.localToGlobal(textBox.size.bottomLeft(Offset.zero)); - final Offset textBottomRight = textBox.localToGlobal(textBox.size.bottomRight(Offset.zero)); - final Offset actionTextBottomLeft = actionTextBox.localToGlobal(actionTextBox.size.bottomLeft(Offset.zero)); - final Offset actionTextBottomRight = actionTextBox.localToGlobal(actionTextBox.size.bottomRight(Offset.zero)); - final Offset snackBarBottomLeft = snackBarBox.localToGlobal(snackBarBox.size.bottomLeft(Offset.zero)); - final Offset snackBarBottomRight = snackBarBox.localToGlobal(snackBarBox.size.bottomRight(Offset.zero)); + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); expect(textBottomLeft.dx - snackBarBottomLeft.dx, 24.0 + 10.0); // margin + left padding expect(snackBarBottomLeft.dy - textBottomLeft.dy, 17.0); // margin (with no bottom padding) @@ -449,6 +441,207 @@ void main() { expect(snackBarBottomRight.dy - actionTextBottomRight.dy, 17.0); // margin (with no bottom padding) }); + testWidgets('SnackBar should push FloatingActionButton above', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only( + left: 10.0, + top: 20.0, + right: 30.0, + bottom: 40.0, + ), + ), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {} + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + ), + )); + + final Offset floatingActionButtonOriginBottomCenter = tester.getCenter(find.byType(FloatingActionButton)); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset snackBarTopCenter = tester.getCenter(find.byType(SnackBar)); + final Offset floatingActionButtonBottomCenter = tester.getCenter(find.byType(FloatingActionButton)); + + expect(floatingActionButtonOriginBottomCenter.dy > floatingActionButtonBottomCenter.dy, true); + expect(snackBarTopCenter.dy > floatingActionButtonBottomCenter.dy, true); + }); + + testWidgets('Floating SnackBar button text alignment', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating,) + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only( + left: 10.0, + top: 20.0, + right: 30.0, + bottom: 40.0, + ), + ), + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + ), + )); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 27.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0); + expect(snackBarBottomRight.dx - actionTextBottomRight.dx, 31.0 + 30.0); // margin + right padding + expect(snackBarBottomRight.dy - actionTextBottomRight.dy, 27.0); // margin (with no bottom padding) + }); + + testWidgets('Floating SnackBar is positioned above BottomNavigationBar', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating,) + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only( + left: 10.0, + top: 20.0, + right: 30.0, + bottom: 40.0, + ), + ), + child: Scaffold( + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.favorite), title: Text('Animutation')), + BottomNavigationBarItem(icon: Icon(Icons.block), title: Text('Zombo.com')), + ], + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + ), + )); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset textBottomLeft = tester.getBottomLeft(find.text('I am a snack bar.')); + final Offset textBottomRight = tester.getBottomRight(find.text('I am a snack bar.')); + final Offset actionTextBottomLeft = tester.getBottomLeft(find.text('ACTION')); + final Offset actionTextBottomRight = tester.getBottomRight(find.text('ACTION')); + final Offset snackBarBottomLeft = tester.getBottomLeft(find.byType(SnackBar)); + final Offset snackBarBottomRight = tester.getBottomRight(find.byType(SnackBar)); + + expect(textBottomLeft.dx - snackBarBottomLeft.dx, 31.0 + 10.0); // margin + left padding + expect(snackBarBottomLeft.dy - textBottomLeft.dy, 27.0); // margin (with no bottom padding) + expect(actionTextBottomLeft.dx - textBottomRight.dx, 16.0); + expect(snackBarBottomRight.dx - actionTextBottomRight.dx, 31.0 + 30.0); // margin + right padding + expect(snackBarBottomRight.dy - actionTextBottomRight.dy, 27.0); // margin (with no bottom padding) + }); + + testWidgets('Floating SnackBar is positioned above FloatingActionButton', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating,) + ), + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.only( + left: 10.0, + top: 20.0, + right: 30.0, + bottom: 40.0, + ), + ), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {} + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + ), + )); + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); // Animation last frame. + + final Offset snackBarBottomCenter = tester.getBottomLeft(find.byType(SnackBar)); + final Offset floatingActionButtonTopCenter = tester.getTopLeft(find.byType(FloatingActionButton)); + + // Since padding and margin is handled inside snackBarBox, + // the bottom offset of snackbar should equal with top offset of FAB + expect(snackBarBottomCenter.dy == floatingActionButtonTopCenter.dy, true); + }); + testWidgets('SnackBarClosedReason', (WidgetTester tester) async { final GlobalKey scaffoldKey = GlobalKey(); bool actionPressed = false; diff --git a/packages/flutter/test/material/snack_bar_theme_test.dart b/packages/flutter/test/material/snack_bar_theme_test.dart new file mode 100644 index 00000000000..4ebfb262b4b --- /dev/null +++ b/packages/flutter/test/material/snack_bar_theme_test.dart @@ -0,0 +1,290 @@ +// Copyright 2019 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. + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('SnackBarThemeData copyWith, ==, hashCode basics', () { + expect(const SnackBarThemeData(), const SnackBarThemeData().copyWith()); + expect(const SnackBarThemeData().hashCode, const SnackBarThemeData().copyWith().hashCode); + }); + + test('SnackBarThemeData null fields by default', () { + const SnackBarThemeData snackBarTheme = SnackBarThemeData(); + expect(snackBarTheme.backgroundColor, null); + expect(snackBarTheme.actionTextColor, null); + expect(snackBarTheme.disabledActionTextColor, null); + expect(snackBarTheme.elevation, null); + expect(snackBarTheme.shape, null); + expect(snackBarTheme.behavior, null); + }); + + testWidgets('Default SnackBarThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const SnackBarThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('SnackBarThemeData implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + SnackBarThemeData( + backgroundColor: const Color(0xFFFFFFFF), + actionTextColor: const Color(0xFF0000AA), + disabledActionTextColor: const Color(0xFF00AA00), + elevation: 2.0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2.0)), + behavior: SnackBarBehavior.floating, + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'backgroundColor: Color(0xffffffff)', + 'actionTextColor: Color(0xff0000aa)', + 'disabledActionTextColor: Color(0xff00aa00)', + 'elevation: 2.0', + 'shape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.circular(2.0))', + 'behavior: SnackBarBehavior.floating' + ]); + }); + + testWidgets('Passing no SnackBarThemeData returns defaults', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + )); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Material material = _getSnackBarMaterial(tester); + + expect(material.color, const Color(0xFF323232)); + expect(material.elevation, 6.0); + expect(material.shape, null); + }); + + testWidgets('SnackBar uses values from SnackBarThemeData', (WidgetTester tester) async { + final SnackBarThemeData snackBarTheme = _snackBarTheme(); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(snackBarTheme: snackBarTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + } + ), + ), + )); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Material material = _getSnackBarMaterial(tester); + final RawMaterialButton button = _getSnackBarButton(tester); + + expect(material.color, snackBarTheme.backgroundColor); + expect(material.elevation, snackBarTheme.elevation); + expect(material.shape, snackBarTheme.shape); + expect(button.textStyle.color, snackBarTheme.actionTextColor); + }); + + testWidgets('SnackBar widget properties take priority over theme', (WidgetTester tester) async { + const Color backgroundColor = Colors.purple; + const Color textColor = Colors.pink; + const double elevation = 7.0; + const ShapeBorder shape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(9.0)), + ); + + await tester.pumpWidget(MaterialApp( + theme: ThemeData(snackBarTheme: _snackBarTheme()), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + backgroundColor: backgroundColor, + elevation: elevation, + shape: shape, + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + textColor: textColor, + label: 'ACTION', + onPressed: () {}, + ), + )); + }, + child: const Text('X'), + ); + } + ), + ), + )); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Material material = _getSnackBarMaterial(tester); + final RawMaterialButton button = _getSnackBarButton(tester); + + expect(material.color, backgroundColor); + expect(material.elevation, elevation); + expect(material.shape, shape); + expect(button.textStyle.color, textColor); + }); + + testWidgets('SnackBar theme behavior is correct for floating', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating,) + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + }, + ), + ), + )); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); + final RenderBox floatingActionButtonBox = tester.firstRenderObject(find.byType(FloatingActionButton)); + + final Offset snackBarBottomCenter = snackBarBox.localToGlobal(snackBarBox.size.bottomCenter(Offset.zero)); + final Offset floatingActionButtonTopCenter = floatingActionButtonBox.localToGlobal(floatingActionButtonBox.size.topCenter(Offset.zero)); + + // Since padding and margin is handled inside snackBarBox, + // the bottom offset of snackbar should equal with top offset of FAB + expect(snackBarBottomCenter.dy == floatingActionButtonTopCenter.dy, true); + }); + + testWidgets('SnackBar theme behavior is correct for fixed', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.fixed,) + ), + home: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.send), + onPressed: () {}, + ), + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar(SnackBar( + content: const Text('I am a snack bar.'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + )); + }, + child: const Text('X'), + ); + }, + ), + ), + )); + + final RenderBox floatingActionButtonOriginBox= tester.firstRenderObject(find.byType(FloatingActionButton)); + final Offset floatingActionButtonOriginBottomCenter = floatingActionButtonOriginBox.localToGlobal(floatingActionButtonOriginBox.size.bottomCenter(Offset.zero)); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final RenderBox snackBarBox = tester.firstRenderObject(find.byType(SnackBar)); + final RenderBox floatingActionButtonBox = tester.firstRenderObject(find.byType(FloatingActionButton)); + + final Offset snackBarTopCenter = snackBarBox.localToGlobal(snackBarBox.size.topCenter(Offset.zero)); + final Offset floatingActionButtonBottomCenter = floatingActionButtonBox.localToGlobal(floatingActionButtonBox.size.bottomCenter(Offset.zero)); + + expect(floatingActionButtonOriginBottomCenter.dy > floatingActionButtonBottomCenter.dy, true); + expect(snackBarTopCenter.dy > floatingActionButtonBottomCenter.dy, true); + }); +} + +SnackBarThemeData _snackBarTheme() { + return SnackBarThemeData( + backgroundColor: Colors.orange, + elevation: 12.0, + shape: BeveledRectangleBorder(borderRadius: BorderRadius.circular(12)), + actionTextColor: Colors.green, + ); +} + +Material _getSnackBarMaterial(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ).first, + ); +} + +RawMaterialButton _getSnackBarButton(WidgetTester tester) { + return tester.widget( + find.descendant( + of: find.byType(SnackBar), + matching: find.byType(RawMaterialButton), + ).first, + ); +}