From d58855c49986d46588b0e2b1bf2f8b2bb97e3932 Mon Sep 17 00:00:00 2001 From: Eilidh Southren Date: Tue, 29 Nov 2022 18:02:34 +0000 Subject: [PATCH] Update SnackBar to support Material 3 (#115750) * Add M2 defaults and template skeleton * add MaterialStateColor functionality to ActionTextColor (issue #110402) * Add M2 defaults and template skeleton * updated material 3 tokens * Updated snackbar demo * add theme tests * add gen defaults * formatting * more whitespace fixes * add widget type * update docs * code review changes * Add line overflow functionality * whitespace fixes * update M3 animation * whitespace fixes * add insetPadding param * Modifed icon parameter to showCloseIcon * white space fixes * test fixes * rename iconColor to closeIconColor * debug test fix * de-britishification --- dev/tools/gen_defaults/bin/gen_defaults.dart | 2 + .../gen_defaults/lib/snackbar_template.dart | 76 ++++ .../lib/material/snack_bar/snack_bar.2.dart | 168 ++++++++ .../flutter/lib/src/material/snack_bar.dart | 403 ++++++++++++++---- .../lib/src/material/snack_bar_theme.dart | 37 +- .../flutter/lib/src/material/theme_data.dart | 1 + .../flutter/test/material/debug_test.dart | 1 + .../flutter/test/material/snack_bar_test.dart | 141 +++++- .../test/material/snack_bar_theme_test.dart | 41 +- 9 files changed, 781 insertions(+), 89 deletions(-) create mode 100644 dev/tools/gen_defaults/lib/snackbar_template.dart create mode 100644 examples/api/lib/material/snack_bar/snack_bar.2.dart diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index de9b2b894473..e8a6364a5849 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -44,6 +44,7 @@ import 'package:gen_defaults/progress_indicator_template.dart'; import 'package:gen_defaults/radio_template.dart'; import 'package:gen_defaults/segmented_button_template.dart'; import 'package:gen_defaults/slider_template.dart'; +import 'package:gen_defaults/snackbar_template.dart'; import 'package:gen_defaults/surface_tint.dart'; import 'package:gen_defaults/switch_template.dart'; import 'package:gen_defaults/text_field_template.dart'; @@ -161,6 +162,7 @@ Future main(List args) async { ProgressIndicatorTemplate('ProgressIndicator', '$materialLib/progress_indicator.dart', tokens).updateFile(); RadioTemplate('Radio', '$materialLib/radio.dart', tokens).updateFile(); SegmentedButtonTemplate('SegmentedButton', '$materialLib/segmented_button.dart', tokens).updateFile(); + SnackbarTemplate('md.comp.snackbar', 'Snackbar', '$materialLib/snack_bar.dart', tokens).updateFile(); SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile(); SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile(); SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/lib/snackbar_template.dart b/dev/tools/gen_defaults/lib/snackbar_template.dart new file mode 100644 index 000000000000..47aad664cf06 --- /dev/null +++ b/dev/tools/gen_defaults/lib/snackbar_template.dart @@ -0,0 +1,76 @@ +// Copyright 2014 The Flutter 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 'template.dart'; + +class SnackbarTemplate extends TokenTemplate { + const SnackbarTemplate( + this.tokenGroup, super.blockName, super.fileName, super.tokens, { + super.colorSchemePrefix = '_colors.' + }); + + final String tokenGroup; + + @override + String generate() => ''' +class _${blockName}DefaultsM3 extends SnackBarThemeData { + _${blockName}DefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color get backgroundColor => ${componentColor("$tokenGroup.container")}; + + @override + Color get actionTextColor => MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return ${componentColor("$tokenGroup.action.pressed.label-text")}; + } + if (states.contains(MaterialState.pressed)) { + return ${componentColor("$tokenGroup.action.pressed.label-text")}; + } + if (states.contains(MaterialState.hovered)) { + return ${componentColor("$tokenGroup.action.hover.label-text")}; + } + if (states.contains(MaterialState.focused)) { + return ${componentColor("$tokenGroup.action.focus.label-text")}; + } + return ${componentColor("$tokenGroup.action.label-text")}; + }); + + @override + Color get disabledActionTextColor => + ${componentColor("$tokenGroup.action.pressed.label-text")}; + + + @override + TextStyle get contentTextStyle => + ${textStyle("$tokenGroup.supporting-text")}!.copyWith + (color: ${componentColor("$tokenGroup.supporting-text")}, + ); + + @override + double get elevation => ${elevation("$tokenGroup.container")}; + + @override + ShapeBorder get shape => ${shape("$tokenGroup.container")}; + + @override + SnackBarBehavior get behavior => SnackBarBehavior.fixed; + + @override + EdgeInsets get insetPadding => const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0); + + @override + bool get showCloseIcon => false; + + @override + Color get iconColor => _colors.onInverseSurface; + } + +'''; +} diff --git a/examples/api/lib/material/snack_bar/snack_bar.2.dart b/examples/api/lib/material/snack_bar/snack_bar.2.dart new file mode 100644 index 000000000000..6eb17467017c --- /dev/null +++ b/examples/api/lib/material/snack_bar/snack_bar.2.dart @@ -0,0 +1,168 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Flutter code sample for [SnackBar] with Material 3 specifications. + +import 'package:flutter/material.dart'; + +void main() => runApp(const MyApp()); + +// A Material 3 [SnackBar] demonstrating an optional icon, in either floating +// or fixed format. +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + static const String _title = 'Flutter Code Sample'; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: _title, + theme: ThemeData(useMaterial3: true), + home: Scaffold( + appBar: AppBar(title: const Text(_title)), + body: const Center( + child: SnackBarExample(), + ), + ), + ); + } +} + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State { + SnackBarBehavior? _snackBarBehavior = SnackBarBehavior.floating; + bool _withIcon = true; + bool _withAction = true; + bool _multiLine = false; + bool _longActionLabel = false; + + Padding _configRow(List children) => Padding( + padding: const EdgeInsets.all(8.0), child: Row(children: children)); + + @override + Widget build(BuildContext context) { + return Padding(padding: const EdgeInsets.only(left: 50.0), child: Column( + children: [ + _configRow([ + Text('Snack Bar configuration', + style: Theme.of(context).textTheme.bodyLarge), + ]), + _configRow( + [ + const Text('Fixed'), + Radio( + value: SnackBarBehavior.fixed, + groupValue: _snackBarBehavior, + onChanged: (SnackBarBehavior? value) { + setState(() { + _snackBarBehavior = value; + }); + }, + ), + const Text('Floating'), + Radio( + value: SnackBarBehavior.floating, + groupValue: _snackBarBehavior, + onChanged: (SnackBarBehavior? value) { + setState(() { + _snackBarBehavior = value; + }); + }, + ), + ], + ), + _configRow( + [ + const Text('Include Icon '), + Switch( + value: _withIcon, + onChanged: (bool value) { + setState(() { + _withIcon = !_withIcon; + }); + }, + ), + ], + ), + _configRow( + [ + const Text('Include Action '), + Switch( + value: _withAction, + onChanged: (bool value) { + setState(() { + _withAction = !_withAction; + }); + }, + ), + const SizedBox(width: 16.0), + const Text('Long Action Label '), + Switch( + value: _longActionLabel, + onChanged: !_withAction + ? null + : (bool value) { + setState(() { + _longActionLabel = !_longActionLabel; + }); + }, + ), + ], + ), + _configRow( + [ + const Text('Multi Line Text'), + Switch( + value: _multiLine, + onChanged: _snackBarBehavior == SnackBarBehavior.fixed ? null : (bool value) { + setState(() { + _multiLine = !_multiLine; + }); + }, + ), + ], + ), + const SizedBox(height: 16.0), + ElevatedButton( + child: const Text('Show Snackbar'), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar(_snackBar()); + } + ), + ], + ), + ); +} + + SnackBar _snackBar() { + final SnackBarAction? action = _withAction + ? SnackBarAction( + label: _longActionLabel ? 'Long Action Text' : 'Action', + onPressed: () { + // Code to execute. + }, + ) + : null; + final double? width = + _snackBarBehavior == SnackBarBehavior.floating && _multiLine ? 400.0 : null; + final String label = _multiLine + ? 'A Snack Bar with quite a lot of text which spans across multiple lines' + : 'Single Line Snack Bar'; + return SnackBar( + content: Text(label), + showCloseIcon: _withIcon, + width: width, + behavior: _snackBarBehavior, + action: action, + duration: const Duration(seconds: 3), + ); + } +} diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index e56fc8532751..3f6156b63b64 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -7,6 +7,8 @@ import 'package:flutter/widgets.dart'; import 'button_style.dart'; import 'color_scheme.dart'; +import 'icon_button.dart'; +import 'icons.dart'; import 'material.dart'; import 'material_state.dart'; import 'scaffold.dart'; @@ -19,17 +21,13 @@ import 'theme.dart'; // late BuildContext context; const double _singleLineVerticalPadding = 14.0; - -// 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, https://github.com/flutter/flutter/issues/32782 -// See https://material.io/components/snackbars#specs, 'Longer Action Text' does -// not match spec. - const Duration _snackBarTransitionDuration = Duration(milliseconds: 250); const Duration _snackBarDisplayDuration = Duration(milliseconds: 4000); const Curve _snackBarHeightCurve = Curves.fastOutSlowIn; -const Curve _snackBarFadeInCurve = Interval(0.45, 1.0, curve: Curves.fastOutSlowIn); +const Curve _snackBarM3HeightCurve = Curves.easeInOutQuart; + +const Curve _snackBarFadeInCurve = Interval(0.4, 1.0); +const Curve _snackBarM3FadeInCurve = Interval(0.4, 0.6, curve: Curves.easeInCirc); const Curve _snackBarFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); /// Specify how a [SnackBar] was closed. @@ -97,6 +95,11 @@ class SnackBarAction extends StatefulWidget { /// The button label color. If not provided, defaults to /// [SnackBarThemeData.actionTextColor]. + /// + /// If [textColor] is a [MaterialStateColor], then the text color will be + /// be resolved against the set of [MaterialState]s that the action text + /// is in, thus allowing for different colors for states such as pressed, + /// hovered and others. final Color? textColor; /// The button disabled label color. This color is shown after the @@ -132,17 +135,36 @@ class _SnackBarActionState extends State { @override Widget build(BuildContext context) { - Color? resolveForegroundColor(Set states) { - final SnackBarThemeData snackBarTheme = Theme.of(context).snackBarTheme; - if (states.contains(MaterialState.disabled)) { - return widget.disabledTextColor ?? snackBarTheme.disabledActionTextColor; + final SnackBarThemeData defaults = Theme.of(context).useMaterial3 + ? _SnackbarDefaultsM3(context) + : _SnackbarDefaultsM2(context); + final SnackBarThemeData snackBarTheme = Theme.of(context).snackBarTheme; + + MaterialStateColor resolveForegroundColor() { + if (widget.textColor is MaterialStateColor) { + return widget.textColor! as MaterialStateColor; + } + if (snackBarTheme.actionTextColor is MaterialStateColor) { + return snackBarTheme.actionTextColor! as MaterialStateColor; + } + if (defaults.actionTextColor is MaterialStateColor) { + return defaults.actionTextColor! as MaterialStateColor; } - return widget.textColor ?? snackBarTheme.actionTextColor; + return MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return widget.disabledTextColor ?? + snackBarTheme.disabledActionTextColor ?? + defaults.disabledActionTextColor!; + } + return widget.textColor ?? + snackBarTheme.actionTextColor ?? + defaults.actionTextColor!; + }); } return TextButton( style: ButtonStyle( - foregroundColor: MaterialStateProperty.resolveWith(resolveForegroundColor), + foregroundColor: resolveForegroundColor(), ), onPressed: _haveTriggeredAction ? null : _handlePressed, child: Text(widget.label), @@ -213,6 +235,8 @@ class SnackBar extends StatefulWidget { this.shape, this.behavior, this.action, + this.showCloseIcon, + this.closeIconColor, this.duration = _snackBarDisplayDuration, this.animation, this.onVisible, @@ -255,7 +279,8 @@ class SnackBar extends StatefulWidget { /// 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 + /// If this property is null, then [SnackBarThemeData.insetPadding] of + /// [ThemeData.snackBarTheme] is used. If that is also null, then the default is /// `EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0)`. final EdgeInsetsGeometry? margin; @@ -319,6 +344,9 @@ class SnackBar extends StatefulWidget { /// If this property is null, then [SnackBarThemeData.behavior] of /// [ThemeData.snackBarTheme] is used. If that is null, then the default is /// [SnackBarBehavior.fixed]. + /// + /// If this value is [SnackBarBehavior.floating], the length of the bar + /// is defined by either [width] or [margin]. final SnackBarBehavior? behavior; /// (optional) An action that the user can take based on the snack bar. @@ -329,6 +357,24 @@ class SnackBar extends StatefulWidget { /// The action should not be "dismiss" or "cancel". final SnackBarAction? action; + /// (optional) Whether to include a "close" icon widget. + /// + /// Tapping the icon will close the snack bar. + final bool? showCloseIcon; + + /// (optional) An optional color for the close icon, if [showCloseIcon] is + /// true. + /// + /// If this property is null, then [SnackBarThemeData.closeIconColor] of + /// [ThemeData.snackBarTheme] is used. If that is null, then the default is + /// inverse surface. + /// + /// If [closeIconColor] is a [MaterialStateColor], then the icon color will be + /// be resolved against the set of [MaterialState]s that the action text + /// is in, thus allowing for different colors for states such as pressed, + /// hovered and others. + final Color? closeIconColor; + /// The amount of time the snack bar should be displayed. /// /// Defaults to 4.0s. @@ -384,6 +430,8 @@ class SnackBar extends StatefulWidget { shape: shape, behavior: behavior, action: action, + showCloseIcon: showCloseIcon, + closeIconColor: closeIconColor, duration: duration, animation: newAnimation, onVisible: onVisible, @@ -443,34 +491,38 @@ class _SnackBarState extends State { final ColorScheme colorScheme = theme.colorScheme; final SnackBarThemeData snackBarTheme = theme.snackBarTheme; final bool isThemeDark = theme.brightness == Brightness.dark; - final Color buttonColor = isThemeDark ? colorScheme.primary : colorScheme.secondary; + final Color buttonColor = isThemeDark ? colorScheme.primary : colorScheme.secondary; + final SnackBarThemeData defaults = theme.useMaterial3 + ? _SnackbarDefaultsM3(context) + : _SnackbarDefaultsM2(context); // 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 = theme.copyWith( - colorScheme: ColorScheme( - primary: colorScheme.onPrimary, - primaryVariant: colorScheme.onPrimary, - secondary: buttonColor, - 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, - ), - ); - final TextStyle? contentTextStyle = snackBarTheme.contentTextStyle ?? ThemeData(brightness: brightness).textTheme.titleMedium; - final SnackBarBehavior snackBarBehavior = widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed; + // Invert the theme values for Material 2. Material 3 values are tokenzied to pre-inverted values. + final ThemeData effectiveTheme = theme.useMaterial3 + ? theme + : theme.copyWith( + colorScheme: ColorScheme( + primary: colorScheme.onPrimary, + primaryVariant: colorScheme.onPrimary, + secondary: buttonColor, + secondaryVariant: colorScheme.onSecondary, + surface: colorScheme.onSurface, + background: defaults.backgroundColor!, + error: colorScheme.onError, + onPrimary: colorScheme.primary, + onSecondary: colorScheme.secondary, + onSurface: colorScheme.surface, + onBackground: colorScheme.background, + onError: colorScheme.error, + brightness: brightness, + ), + ); + + final TextStyle? contentTextStyle = snackBarTheme.contentTextStyle ?? defaults.contentTextStyle; + final SnackBarBehavior snackBarBehavior = widget.behavior ?? snackBarTheme.behavior ?? defaults.behavior!; final double? width = widget.width ?? snackBarTheme.width; assert((){ // Whether the behavior is set through the constructor or the theme, @@ -492,47 +544,116 @@ class _SnackBarState extends State { return true; }()); + final bool showCloseIcon = widget.showCloseIcon ?? snackBarTheme.showCloseIcon ?? defaults.showCloseIcon!; + final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating; final double horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0; - final EdgeInsetsGeometry padding = widget.padding - ?? EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.action != null ? 0 : horizontalPadding); + final EdgeInsetsGeometry padding = widget.padding ?? + EdgeInsetsDirectional.only( + start: horizontalPadding, + end: widget.action != null || showCloseIcon + ? 0 + : horizontalPadding); final double actionHorizontalMargin = (widget.padding?.resolve(TextDirection.ltr).right ?? horizontalPadding) / 2; + final double iconHorizontalMargin = (widget.padding?.resolve(TextDirection.ltr).right ?? horizontalPadding) / 12.0; final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation!, curve: _snackBarHeightCurve); final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: widget.animation!, curve: _snackBarFadeInCurve); + final CurvedAnimation fadeInM3Animation = CurvedAnimation(parent: widget.animation!, curve: _snackBarM3FadeInCurve); + final CurvedAnimation fadeOutAnimation = CurvedAnimation( parent: widget.animation!, curve: _snackBarFadeOutCurve, reverseCurve: const Threshold(0.0), ); + // Material 3 Animation has a height animation on entry, but a direct fade out on exit. + final CurvedAnimation heightM3Animation = CurvedAnimation( + parent: widget.animation!, + curve: _snackBarM3HeightCurve, + reverseCurve: const Threshold(0.0), + ); - Widget snackBar = Padding( - padding: padding, - child: Row( - children: [ - Expanded( - child: Container( - padding: widget.padding == null ? const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding) : null, - child: DefaultTextStyle( - style: contentTextStyle!, - child: widget.content, + + final IconButton? iconButton = showCloseIcon + ? IconButton( + icon: const Icon(Icons.close), + iconSize: 24.0, + color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, + onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), + ) + : null; + + // Calculate combined width of Action, Icon, and their padding, if they are present. + final TextPainter actionTextPainter = TextPainter( + text: TextSpan( + text: widget.action?.label ?? '', + style: Theme.of(context).textTheme.labelLarge, + ), + maxLines: 1, + textDirection: TextDirection.ltr) + ..layout(); + final double actionAndIconWidth = actionTextPainter.size.width + + (widget.action != null ? actionHorizontalMargin : 0) + + (showCloseIcon ? (iconButton?.iconSize ?? 0 + iconHorizontalMargin) : 0); + + final EdgeInsets margin = widget.margin?.resolve(TextDirection.ltr) ?? snackBarTheme.insetPadding ?? defaults.insetPadding!; + + final double snackBarWidth = widget.width ?? mediaQueryData.size.width - (margin.left + margin.right); + // Action and Icon will overflow to a new line if their width is greater + // than one quarter of the total Snack Bar width. + final bool actionLineOverflow = + actionAndIconWidth / snackBarWidth > 0.25; + + final List maybeActionAndIcon = [ + if (widget.action != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: actionHorizontalMargin), + child: TextButtonTheme( + data: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: buttonColor, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), ), ), + child: widget.action!, ), - if (widget.action != null) - Padding( - padding: EdgeInsets.symmetric(horizontal: actionHorizontalMargin), - child: TextButtonTheme( - data: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: buttonColor, - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + ), + if (showCloseIcon) + Padding( + padding: EdgeInsets.symmetric(horizontal: iconHorizontalMargin), + child: iconButton, + ), + ]; + + Widget snackBar = Padding( + padding: padding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Container( + padding: widget.padding == null + ? const EdgeInsets.symmetric( + vertical: _singleLineVerticalPadding) + : null, + child: DefaultTextStyle( + style: contentTextStyle!, + child: widget.content, ), ), - child: widget.action!, ), - ), + if(!actionLineOverflow) ...maybeActionAndIcon, + if(actionLineOverflow) SizedBox(width: snackBarWidth*0.4), + ], + ), + if(actionLineOverflow) Padding( + padding: const EdgeInsets.only(bottom: _singleLineVerticalPadding), + child: Row(mainAxisAlignment: MainAxisAlignment.end, + children: maybeActionAndIcon), + ), ], ), ); @@ -544,19 +665,17 @@ class _SnackBarState extends State { ); } - final double elevation = widget.elevation ?? snackBarTheme.elevation ?? 6.0; - final Color backgroundColor = widget.backgroundColor ?? snackBarTheme.backgroundColor ?? inverseTheme.colorScheme.background; - final ShapeBorder? shape = widget.shape - ?? snackBarTheme.shape - ?? (isFloatingSnackBar ? const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))) : null); + final double elevation = widget.elevation ?? snackBarTheme.elevation ?? defaults.elevation!; + final Color backgroundColor = widget.backgroundColor ?? snackBarTheme.backgroundColor ?? defaults.backgroundColor!; + final ShapeBorder? shape = widget.shape ?? snackBarTheme.shape ?? (isFloatingSnackBar ? defaults.shape : null); snackBar = Material( shape: shape, elevation: elevation, color: backgroundColor, child: Theme( - data: inverseTheme, - child: mediaQueryData.accessibleNavigation + data: effectiveTheme, + child: mediaQueryData.accessibleNavigation || theme.useMaterial3 ? snackBar : FadeTransition( opacity: fadeOutAnimation, @@ -566,24 +685,16 @@ class _SnackBarState extends State { ); if (isFloatingSnackBar) { - const double topMargin = 5.0; - const double bottomMargin = 10.0; // If width is provided, do not include horizontal margins. if (width != null) { snackBar = Container( - margin: const EdgeInsets.only(top: topMargin, bottom: bottomMargin), + margin: EdgeInsets.only(top: margin.top, bottom: margin.bottom), width: width, child: snackBar, ); } else { - const double horizontalMargin = 15.0; snackBar = Padding( - padding: widget.margin ?? const EdgeInsets.fromLTRB( - horizontalMargin, - topMargin, - horizontalMargin, - bottomMargin, - ), + padding: margin, child: snackBar, ); } @@ -614,11 +725,27 @@ class _SnackBarState extends State { final Widget snackBarTransition; if (mediaQueryData.accessibleNavigation) { snackBarTransition = snackBar; - } else if (isFloatingSnackBar) { + } else if (isFloatingSnackBar && !theme.useMaterial3) { snackBarTransition = FadeTransition( opacity: fadeInAnimation, child: snackBar, ); + // Is Material 3 Floating Snack Bar. + } else if (isFloatingSnackBar && theme.useMaterial3) { + snackBarTransition = FadeTransition( + opacity: fadeInM3Animation, + child: AnimatedBuilder( + animation: heightM3Animation, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: heightM3Animation.value, + child: child, + ); + }, + child: snackBar, + ), + ); } else { snackBarTransition = AnimatedBuilder( animation: heightAnimation, @@ -643,3 +770,123 @@ class _SnackBarState extends State { ); } } + +// Hand coded defaults based on Material Design 2. +class _SnackbarDefaultsM2 extends SnackBarThemeData { + _SnackbarDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super(elevation: 6.0); + + late final ThemeData _theme; + late final ColorScheme _colors; + + @override + Color get backgroundColor => _theme.brightness == Brightness.light + ? Color.alphaBlend(_colors.onSurface.withOpacity(0.80), _colors.surface) + : _colors.onSurface; + + @override + TextStyle? get contentTextStyle => ThemeData( + brightness: _theme.brightness == Brightness.light + ? Brightness.dark + : Brightness.light) + .textTheme + .titleMedium; + + @override + SnackBarBehavior get behavior => SnackBarBehavior.fixed; + + @override + Color get actionTextColor => _colors.secondary; + + @override + Color get disabledActionTextColor => _colors.onSurface + .withOpacity(_theme.brightness == Brightness.light ? 0.38 : 0.3); + + @override + ShapeBorder get shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(4.0), + ), + ); + + @override + EdgeInsets get insetPadding => const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0); + + @override + bool get showCloseIcon => false; + + @override + Color get closeIconColor => _colors.onSurface; +} + +// BEGIN GENERATED TOKEN PROPERTIES - Snackbar + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_143 + +class _SnackbarDefaultsM3 extends SnackBarThemeData { + _SnackbarDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + late final ColorScheme _colors = _theme.colorScheme; + + @override + Color get backgroundColor => _colors.inverseSurface; + + @override + Color get actionTextColor => MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.inversePrimary; + } + if (states.contains(MaterialState.pressed)) { + return _colors.inversePrimary; + } + if (states.contains(MaterialState.hovered)) { + return _colors.inversePrimary; + } + if (states.contains(MaterialState.focused)) { + return _colors.inversePrimary; + } + return _colors.inversePrimary; + }); + + @override + Color get disabledActionTextColor => + _colors.inversePrimary; + + + @override + TextStyle get contentTextStyle => + Theme.of(context).textTheme.bodyMedium!.copyWith + (color: _colors.onInverseSurface, + ); + + @override + double get elevation => 6.0; + + @override + ShapeBorder get shape => const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(4.0))); + + @override + SnackBarBehavior get behavior => SnackBarBehavior.fixed; + + @override + EdgeInsets get insetPadding => const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0); + + @override + bool get showCloseIcon => false; + + @override + Color get closeIconColor => _colors.onInverseSurface; + } + + +// END GENERATED TOKEN PROPERTIES - Snackbar diff --git a/packages/flutter/lib/src/material/snack_bar_theme.dart b/packages/flutter/lib/src/material/snack_bar_theme.dart index 9086975de7df..cbf21fba8e6d 100644 --- a/packages/flutter/lib/src/material/snack_bar_theme.dart +++ b/packages/flutter/lib/src/material/snack_bar_theme.dart @@ -61,6 +61,9 @@ class SnackBarThemeData with Diagnosticable { this.shape, this.behavior, this.width, + this.insetPadding, + this.showCloseIcon, + this.closeIconColor, }) : assert(elevation == null || elevation >= 0.0), assert( width == null || @@ -115,6 +118,21 @@ class SnackBarThemeData with Diagnosticable { /// [SnackBarBehavior.floating]. final double? width; + /// Overrides the default value for [SnackBar.margin]. + /// + /// This value is only used when [behavior] is [SnackBarBehavior.floating]. + final EdgeInsets? insetPadding; + + /// Overrides the default value for [SnackBar.showCloseIcon]. + /// + /// Whether to show an optional "Close" icon. + final bool? showCloseIcon; + + /// Overrides the default value for [SnackBar.closeIconColor]. + /// + /// This value is only used if [showCloseIcon] is true. + final Color? closeIconColor; + /// Creates a copy of this object with the given fields replaced with the /// new values. SnackBarThemeData copyWith({ @@ -126,6 +144,9 @@ class SnackBarThemeData with Diagnosticable { ShapeBorder? shape, SnackBarBehavior? behavior, double? width, + EdgeInsets? insetPadding, + bool? showCloseIcon, + Color? closeIconColor, }) { return SnackBarThemeData( backgroundColor: backgroundColor ?? this.backgroundColor, @@ -136,6 +157,9 @@ class SnackBarThemeData with Diagnosticable { shape: shape ?? this.shape, behavior: behavior ?? this.behavior, width: width ?? this.width, + insetPadding: insetPadding ?? this.insetPadding, + showCloseIcon: showCloseIcon ?? this.showCloseIcon, + closeIconColor: closeIconColor ?? this.closeIconColor, ); } @@ -155,6 +179,8 @@ class SnackBarThemeData with Diagnosticable { shape: ShapeBorder.lerp(a?.shape, b?.shape, t), behavior: t < 0.5 ? a?.behavior : b?.behavior, width: lerpDouble(a?.width, b?.width, t), + insetPadding: EdgeInsets.lerp(a?.insetPadding, b?.insetPadding, t), + closeIconColor: Color.lerp(a?.closeIconColor, b?.closeIconColor, t), ); } @@ -168,6 +194,9 @@ class SnackBarThemeData with Diagnosticable { shape, behavior, width, + insetPadding, + showCloseIcon, + closeIconColor, ); @override @@ -186,7 +215,10 @@ class SnackBarThemeData with Diagnosticable { && other.elevation == elevation && other.shape == shape && other.behavior == behavior - && other.width == width; + && other.width == width + && other.insetPadding == insetPadding + && other.showCloseIcon == showCloseIcon + && other.closeIconColor == closeIconColor; } @override @@ -200,5 +232,8 @@ class SnackBarThemeData with Diagnosticable { properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); properties.add(DiagnosticsProperty('behavior', behavior, defaultValue: null)); properties.add(DoubleProperty('width', width, defaultValue: null)); + properties.add(DiagnosticsProperty('insetPadding', insetPadding, defaultValue: null)); + properties.add(DiagnosticsProperty('showCloseIcon', showCloseIcon, defaultValue: null)); + properties.add(ColorProperty('closeIconColor', closeIconColor, defaultValue: null)); } } diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 5da898264563..86c75a911c8c 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -1321,6 +1321,7 @@ class ThemeData with Diagnosticable { /// * [Navigation rail](https://m3.material.io/components/navigation-rail): [NavigationRail] /// * Progress indicators: [CircularProgressIndicator], [LinearProgressIndicator] /// * Radio button: [Radio] + /// * Snack bar: [SnackBar] /// * Switch: [Switch] /// * Top app bar: [AppBar] /// diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index 330ab5e56c7b..a979d7cc0678 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -316,6 +316,7 @@ void main() { ' TextButtonTheme\n' ' Padding\n' ' Row\n' + ' Column\n' ' Padding\n' ' MediaQuery\n' ' Padding\n' diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index 53a820a5fab0..4d5a847b878d 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -2340,7 +2340,146 @@ void main() { await expectLater(find.byType(MaterialApp), matchesGoldenFile('snack_bar.goldenTest.backdropFilter.png')); }); - testWidgets('ScaffoldMessenger will alert for snackbars that cannot be presented', (WidgetTester tester) async { + testWidgets('Floating snackbar can display optional icon', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + bottomSheet: SizedBox( + width: 200, + height: 50, + child: ColoredBox( + color: Colors.pink, + ), + ), + ), + )); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar( + SnackBar( + content: const Text('Feeling snackish'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile( + 'snack_bar.goldenTest.floatingWithActionWithIcon.png')); + }); + + testWidgets('Fixed width snackbar can display optional icon', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + bottomSheet: SizedBox( + width: 200, + height: 50, + child: ColoredBox( + color: Colors.pink, + ), + ), + ), + )); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar(SnackBar( + content: const Text('Go get a snack'), + duration: const Duration(seconds: 2), + action: SnackBarAction(label: 'ACTION', onPressed: () {}), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + )); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater(find.byType(MaterialApp), matchesGoldenFile('snack_bar.goldenTest.fixedWithActionWithIcon.png')); + }); + + testWidgets('Fixed snackbar can display optional icon without action', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + bottomSheet: SizedBox( + width: 200, + height: 50, + child: ColoredBox( + color: Colors.pink, + ), + ), + ), + )); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar( + const SnackBar( + content: Text('I wonder if there are snacks nearby?'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.fixed, + showCloseIcon: true, + ), + ); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater(find.byType(MaterialApp), matchesGoldenFile('snack_bar.goldenTest.fixedWithIcon.png')); + }); + + testWidgets( + 'Floating width snackbar can display optional icon without action', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + bottomSheet: SizedBox( + width: 200, + height: 50, + child: ColoredBox( + color: Colors.pink, + ), + ), + ), + )); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar(const SnackBar( + content: Text('Must go get a snack!'), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + )); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater(find.byType(MaterialApp), + matchesGoldenFile('snack_bar.goldenTest.floatingWithIcon.png')); + }); + + testWidgets('Fixed multi-line snackbar with icon is aligned correctly', (WidgetTester tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + bottomSheet: SizedBox( + width: 200, + height: 50, + child: ColoredBox( + color: Colors.pink, + ), + ), + ), + )); + + final ScaffoldMessengerState scaffoldMessengerState = tester.state(find.byType(ScaffoldMessenger)); + scaffoldMessengerState.showSnackBar(const SnackBar( + content: Text( + 'This is a really long snackbar message. So long, it spans across more than one line!'), + duration: Duration(seconds: 2), + showCloseIcon: true, + behavior: SnackBarBehavior.floating, + )); + await tester.pumpAndSettle(); // Have the SnackBar fully animate out. + + await expectLater(find.byType(MaterialApp), + matchesGoldenFile('snack_bar.goldenTest.multiLineWithIcon.png')); + }); + + testWidgets( + 'ScaffoldMessenger will alert for snackbars that cannot be presented', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/103004 await tester.pumpWidget(const MaterialApp( home: Center(), diff --git a/packages/flutter/test/material/snack_bar_theme_test.dart b/packages/flutter/test/material/snack_bar_theme_test.dart index 7162b5df3b5c..2440c43f815d 100644 --- a/packages/flutter/test/material/snack_bar_theme_test.dart +++ b/packages/flutter/test/material/snack_bar_theme_test.dart @@ -22,6 +22,9 @@ void main() { expect(snackBarTheme.shape, null); expect(snackBarTheme.behavior, null); expect(snackBarTheme.width, null); + expect(snackBarTheme.insetPadding, null); + expect(snackBarTheme.showCloseIcon, null); + expect(snackBarTheme.closeIconColor, null); }); test( @@ -59,6 +62,9 @@ void main() { shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2.0))), behavior: SnackBarBehavior.floating, width: 400.0, + insetPadding: EdgeInsets.all(10.0), + showCloseIcon: false, + closeIconColor: Color(0xFF0000AA), ).debugFillProperties(builder); final List description = builder.properties @@ -75,6 +81,9 @@ void main() { 'shape: RoundedRectangleBorder(BorderSide(width: 0.0, style: none), BorderRadius.circular(2.0))', 'behavior: SnackBarBehavior.floating', 'width: 400.0', + 'insetPadding: EdgeInsets.all(10.0)', + 'showCloseIcon: false', + 'closeIconColor: Color(0xff0000aa)', ]); }); @@ -115,7 +124,7 @@ void main() { testWidgets('SnackBar uses values from SnackBarThemeData', (WidgetTester tester) async { const String text = 'I am a snack bar.'; const String action = 'ACTION'; - final SnackBarThemeData snackBarTheme = _snackBarTheme(); + final SnackBarThemeData snackBarTheme = _snackBarTheme(showCloseIcon: true); await tester.pumpWidget(MaterialApp( theme: ThemeData(snackBarTheme: snackBarTheme), @@ -144,12 +153,14 @@ void main() { final Material material = _getSnackBarMaterial(tester); final RenderParagraph button = _getSnackBarActionTextRenderObject(tester, action); final RenderParagraph content = _getSnackBarTextRenderObject(tester, text); + final Icon icon = _getSnackBarIcon(tester); expect(content.text.style, snackBarTheme.contentTextStyle); expect(material.color, snackBarTheme.backgroundColor); expect(material.elevation, snackBarTheme.elevation); expect(material.shape, snackBarTheme.shape); expect(button.text.style!.color, snackBarTheme.actionTextColor); + expect(icon.icon, Icons.close); }); testWidgets('SnackBar widget properties take priority over theme', (WidgetTester tester) async { @@ -163,7 +174,7 @@ void main() { const double snackBarWidth = 400.0; await tester.pumpWidget(MaterialApp( - theme: ThemeData(snackBarTheme: _snackBarTheme()), + theme: ThemeData(snackBarTheme: _snackBarTheme(showCloseIcon: true)), home: Scaffold( body: Builder( builder: (BuildContext context) { @@ -182,6 +193,7 @@ void main() { label: action, onPressed: () {}, ), + showCloseIcon: false, )); }, child: const Text('X'), @@ -204,6 +216,7 @@ void main() { expect(material.elevation, elevation); expect(material.shape, shape); expect(button.text.style!.color, textColor); + expect(_getSnackBarIconFinder(tester), findsNothing); // Assert width. final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder.first); final Offset snackBarBottomRight = tester.getBottomRight(materialFinder.first); @@ -214,8 +227,7 @@ void main() { testWidgets('SnackBar theme behavior is correct for floating', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( theme: ThemeData( - snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), - ), + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating)), home: Scaffold( floatingActionButton: FloatingActionButton( child: const Icon(Icons.send), @@ -389,13 +401,14 @@ void main() { }); } -SnackBarThemeData _snackBarTheme() { - return const SnackBarThemeData( +SnackBarThemeData _snackBarTheme({bool? showCloseIcon}) { + return SnackBarThemeData( backgroundColor: Colors.orange, actionTextColor: Colors.green, - contentTextStyle: TextStyle(color: Colors.blue), + contentTextStyle: const TextStyle(color: Colors.blue), elevation: 12.0, - shape: BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + showCloseIcon: showCloseIcon, + shape: const BeveledRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), ); } @@ -409,7 +422,6 @@ Finder _getSnackBarMaterialFinder(WidgetTester tester) { return find.descendant( of: find.byType(SnackBar), matching: find.byType(Material), - ); } @@ -420,6 +432,17 @@ RenderParagraph _getSnackBarActionTextRenderObject(WidgetTester tester, String t )); } +Icon _getSnackBarIcon(WidgetTester tester) { + return tester.widget(_getSnackBarIconFinder(tester)); +} + +Finder _getSnackBarIconFinder(WidgetTester tester) { + return find.descendant( + of: find.byType(SnackBar), + matching: find.byIcon(Icons.close), + ); +} + RenderParagraph _getSnackBarTextRenderObject(WidgetTester tester, String text) { return tester.renderObject(find.descendant( of: find.byType(SnackBar),