diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 42a5c94af5..7859eacbd4 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -409,6 +409,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { @required this.floatingActionButtonMoveAnimationProgress, @required this.floatingActionButtonMotionAnimator, @required this.isSnackBarFloating, + @required this.snackBarWidth, @required this.extendBody, @required this.extendBodyBehindAppBar, }) : assert(minInsets != null), @@ -432,6 +433,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { final FloatingActionButtonAnimator floatingActionButtonMotionAnimator; final bool isSnackBarFloating; + final double snackBarWidth; @override void performLayout(Size size) { @@ -563,8 +565,12 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { } if (hasChild(_ScaffoldSlot.snackBar)) { + final bool hasCustomWidth = snackBarWidth != null && snackBarWidth < size.width; if (snackBarSize == Size.zero) { - snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); + snackBarSize = layoutChild( + _ScaffoldSlot.snackBar, + hasCustomWidth ? looseConstraints : fullWidthConstraints, + ); } double snackBarYOffsetBase; @@ -574,7 +580,8 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { snackBarYOffsetBase = contentBottom; } - positionChild(_ScaffoldSlot.snackBar, Offset(0.0, snackBarYOffsetBase - snackBarSize.height)); + final double xOffset = hasCustomWidth ? (size.width - snackBarWidth) / 2 : 0.0; + positionChild(_ScaffoldSlot.snackBar, Offset(xOffset, snackBarYOffsetBase - snackBarSize.height)); } if (hasChild(_ScaffoldSlot.statusBar)) { @@ -2381,11 +2388,13 @@ class ScaffoldState extends State with TickerProviderStateMixin { } bool isSnackBarFloating = false; + double snackBarWidth; if (_snackBars.isNotEmpty) { final SnackBarBehavior snackBarBehavior = _snackBars.first._widget.behavior ?? themeData.snackBarTheme.behavior ?? SnackBarBehavior.fixed; isSnackBarFloating = snackBarBehavior == SnackBarBehavior.floating; + snackBarWidth = _snackBars.first._widget.width; _addIfNonNull( children, @@ -2541,6 +2550,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { previousFloatingActionButtonLocation: _previousFloatingActionButtonLocation, textDirection: textDirection, isSnackBarFloating: isSnackBarFloating, + snackBarWidth: snackBarWidth, ), ); }), diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index cd114c5adb..07373d0923 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -173,6 +173,9 @@ class SnackBar extends StatefulWidget { @required this.content, this.backgroundColor, this.elevation, + this.margin, + this.padding, + this.width, this.shape, this.behavior, this.action, @@ -181,6 +184,18 @@ class SnackBar extends StatefulWidget { this.onVisible, }) : assert(elevation == null || elevation >= 0.0), assert(content != null), + assert( + margin == null || behavior == SnackBarBehavior.floating, + 'Margin can only be used with floating behavior', + ), + assert( + width == null || behavior == SnackBarBehavior.floating, + 'Width can only be used with floating behavior', + ), + assert( + width == null || margin == null, + 'Width and margin can not be used together', + ), assert(duration != null), super(key: key); @@ -189,7 +204,7 @@ class SnackBar extends StatefulWidget { /// Typically a [Text] widget. final Widget content; - /// The Snackbar's background color. If not specified it will use + /// The snack bar'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. @@ -204,6 +219,34 @@ class SnackBar extends StatefulWidget { /// used, if that is also null, the default value is 6.0. final double elevation; + /// Empty space to surround the snack bar. + /// + /// This property is only used when [behavior] is [SnackBarBehavior.floating]. + /// It can not be used if [width] is specified. + /// + /// If this property is null, then the default is + /// `EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0)`. + final EdgeInsetsGeometry margin; + + /// The amount of padding to apply to the snack bar's content and optional + /// action. + /// + /// If this property is null, then the default depends on the [behavior] and + /// the presence of an [action]. The start padding is 24 if [behavior] is + /// [SnackBarBehavior.fixed] and 16 if it is [SnackBarBehavior.floating]. If + /// there is no [action], the same padding is added to the end. + final EdgeInsetsGeometry padding; + + /// The width of the snack bar. + /// + /// If width is specified, the snack bar will be centered horizontally in the + /// available space. This property is only used when [behavior] is + /// [SnackBarBehavior.floating]. It can not be used if [margin] is specified. + /// + /// If this property is null, then the snack bar will take up the full device + /// width less the margin. + final double width; + /// The shape of the snack bar's [Material]. /// /// Defines the snack bar's [Material.shape]. @@ -272,6 +315,9 @@ class SnackBar extends StatefulWidget { content: content, backgroundColor: backgroundColor, elevation: elevation, + margin: margin, + padding: padding, + width: width, shape: shape, behavior: behavior, action: action, @@ -365,7 +411,9 @@ class _SnackBarState extends State { final TextStyle contentTextStyle = snackBarTheme.contentTextStyle ?? inverseTheme.textTheme.subtitle1; final SnackBarBehavior snackBarBehavior = widget.behavior ?? snackBarTheme.behavior ?? SnackBarBehavior.fixed; final bool isFloatingSnackBar = snackBarBehavior == SnackBarBehavior.floating; - final double snackBarPadding = isFloatingSnackBar ? 16.0 : 24.0; + final double horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0; + final EdgeInsetsGeometry padding = widget.padding + ?? EdgeInsetsDirectional.only(start: horizontalPadding, end: widget.action != null ? 0 : horizontalPadding); final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarHeightCurve); final CurvedAnimation fadeInAnimation = CurvedAnimation(parent: widget.animation, curve: _snackBarFadeInCurve); @@ -375,13 +423,11 @@ class _SnackBarState extends State { reverseCurve: const Threshold(0.0), ); - Widget snackBar = SafeArea( - top: false, - bottom: !isFloatingSnackBar, + Widget snackBar = Padding( + padding: padding, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox(width: snackBarPadding), Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: _singleLineVerticalPadding), @@ -395,15 +441,20 @@ class _SnackBarState extends State { ButtonTheme( textTheme: ButtonTextTheme.accent, minWidth: 64.0, - padding: EdgeInsets.symmetric(horizontal: snackBarPadding), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), child: widget.action, - ) - else - SizedBox(width: snackBarPadding), + ), ], ), ); + if (!isFloatingSnackBar) { + snackBar = SafeArea( + top: false, + child: snackBar, + ); + } + final double elevation = widget.elevation ?? snackBarTheme.elevation ?? 6.0; final Color backgroundColor = widget.backgroundColor ?? snackBarTheme.backgroundColor ?? inverseTheme.backgroundColor; final ShapeBorder shape = widget.shape @@ -426,8 +477,30 @@ class _SnackBarState extends State { ); if (isFloatingSnackBar) { - snackBar = Padding( - padding: const EdgeInsets.fromLTRB(15.0, 5.0, 15.0, 10.0), + const double topMargin = 5.0; + const double bottomMargin = 10.0; + // If width is provided, do not include horizontal margins. + if (widget.width != null) { + snackBar = Container( + margin: const EdgeInsets.only(top: topMargin, bottom: bottomMargin), + width: widget.width, + child: snackBar, + ); + } else { + const double horizontalMargin = 15.0; + snackBar = Padding( + padding: widget.margin ?? const EdgeInsets.fromLTRB( + horizontalMargin, + topMargin, + horizontalMargin, + bottomMargin, + ), + child: snackBar, + ); + } + snackBar = SafeArea( + top: false, + bottom: false, child: snackBar, ); } diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index 5275d2a210..9946603357 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -380,6 +380,129 @@ void main() { expect(renderModel.color, equals(darkTheme.colorScheme.onSurface)); }); + testWidgets('Snackbar margin can be customized', (WidgetTester tester) async { + const double padding = 20.0; + 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.'), + margin: const EdgeInsets.all(padding), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + } + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, padding); + expect(snackBarBottomLeft.dy, 600 - padding); // Device height is 600. + expect(snackBarBottomRight.dx, 800 - padding); // Device width is 800. + }); + + testWidgets('Snackbar padding can be customized', (WidgetTester tester) async { + const double padding = 20.0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + Scaffold.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + padding: EdgeInsets.all(padding), + ), + ); + }, + child: const Text('X'), + ); + } + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder textFinder = find.text('I am a snack bar.'); + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset textBottomLeft = tester.getBottomLeft(textFinder); + final Offset textTopRight = tester.getTopRight(textFinder); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarTopRight = tester.getTopRight(materialFinder); + expect(textBottomLeft.dx - snackBarBottomLeft.dx, padding); + expect(snackBarTopRight.dx - textTopRight.dx, padding); + // The text is given a vertical padding of 14 already. + expect(snackBarBottomLeft.dy - textBottomLeft.dy, padding + 14); + expect(textTopRight.dy - snackBarTopRight.dy, padding + 14); + }); + + testWidgets('Snackbar width can be customized', (WidgetTester tester) async { + const double width = 200.0; + 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.'), + width: width, + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('X'), + ); + } + ), + ), + ), + ); + + await tester.tap(find.text('X')); + await tester.pump(); // start animation + await tester.pump(const Duration(milliseconds: 750)); + + final Finder materialFinder = find.descendant( + of: find.byType(SnackBar), + matching: find.byType(Material), + ); + final Offset snackBarBottomLeft = tester.getBottomLeft(materialFinder); + final Offset snackBarBottomRight = tester.getBottomRight(materialFinder); + expect(snackBarBottomLeft.dx, (800 - width) / 2); // Device width is 800. + expect(snackBarBottomRight.dx, (800 + width) / 2); // Device width is 800. + }); + testWidgets('Snackbar labels can be colored', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp(