diff --git a/packages/flutter/lib/src/material/banner.dart b/packages/flutter/lib/src/material/banner.dart index 0274b78846..fd912d962d 100644 --- a/packages/flutter/lib/src/material/banner.dart +++ b/packages/flutter/lib/src/material/banner.dart @@ -6,8 +6,44 @@ import 'package:flutter/widgets.dart'; import 'banner_theme.dart'; import 'divider.dart'; +import 'scaffold.dart'; import 'theme.dart'; +const Duration _materialBannerTransitionDuration = Duration(milliseconds: 250); +const Curve _materialBannerHeightCurve = Curves.fastOutSlowIn; +const Curve _materialBannerFadeOutCurve = Interval(0.72, 1.0, curve: Curves.fastOutSlowIn); + +/// Specify how a [MaterialBanner] was closed. +/// +/// The [ScaffoldMessengerState.showMaterialBanner] function returns a +/// [ScaffoldFeatureController]. The value of the controller's closed property +/// is a Future that resolves to a MaterialBannerClosedReason. Applications that need +/// to know how a material banner was closed can use this value. +/// +/// Example: +/// +/// ```dart +/// ScaffoldMessenger.of(context).showMaterialBanner( +/// MaterialBanner( ... ) +/// ).closed.then((MaterialBannerClosedReason reason) { +/// ... +/// }); +/// ``` +enum MaterialBannerClosedReason { + /// The material banner was closed through a [SemanticsAction.dismiss]. + dismiss, + + /// The material banner was closed by a user's swipe. + swipe, + + /// The material banner was closed by the [ScaffoldFeatureController] close callback + /// or by calling [ScaffoldMessengerState.hideCurrentMaterialBanner] directly. + hide, + + /// The material banner was closed by a call to [ScaffoldMessengerState.removeCurrentMaterialBanner]. + remove, +} + /// A Material Design banner. /// /// A banner displays an important, succinct message, and provides actions for @@ -19,6 +55,9 @@ import 'theme.dart'; /// interact with them at any time. /// /// {@tool dartpad --template=stateless_widget_material} +/// +/// Banners placed directly into the widget tree are static. +/// /// ```dart /// Widget build(BuildContext context) { /// return Scaffold( @@ -46,6 +85,41 @@ import 'theme.dart'; /// ``` /// {@end-tool} /// +/// {@tool dartpad --template=stateless_widget_material} +/// +/// MaterialBanner's can also be presented through a [ScaffoldMessenger]. +/// Here is an example where ScaffoldMessengerState.showMaterialBanner() is used to show the MaterialBanner. +/// +/// ```dart +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: AppBar( +/// title: const Text('The MaterialBanner is below'), +/// ), +/// body: Center( +/// child: ElevatedButton( +/// child: const Text('Show MaterialBanner'), +/// onPressed: () => ScaffoldMessenger.of(context).showMaterialBanner( +/// const MaterialBanner( +/// padding: EdgeInsets.all(20), +/// content: Text('Hello, I am a Material Banner'), +/// leading: Icon(Icons.agriculture_outlined), +/// backgroundColor: Colors.green, +/// actions: [ +/// TextButton( +/// child: Text('DISMISS'), +/// onPressed: null, +/// ), +/// ], +/// ), +/// ), +/// ), +/// ), +/// ); +/// } +/// ``` +/// {@end-tool} +/// /// The [actions] will be placed beside the [content] if there is only one. /// Otherwise, the [actions] will be placed below the [content]. Use /// [forceActionsBelow] to override this behavior. @@ -59,7 +133,7 @@ import 'theme.dart'; /// [backgroundColor] can be provided to customize the banner. /// /// This widget is unrelated to the widgets library [Banner] widget. -class MaterialBanner extends StatelessWidget { +class MaterialBanner extends StatefulWidget { /// Creates a [MaterialBanner]. /// /// The [actions], [content], and [forceActionsBelow] must be non-null. @@ -75,6 +149,8 @@ class MaterialBanner extends StatelessWidget { this.leadingPadding, this.forceActionsBelow = false, this.overflowAlignment = OverflowBarAlignment.end, + this.animation, + this.onVisible }) : assert(content != null), assert(actions != null), assert(forceActionsBelow != null), @@ -138,18 +214,101 @@ class MaterialBanner extends StatelessWidget { /// Defaults to [OverflowBarAlignment.end]. final OverflowBarAlignment overflowAlignment; + /// The animation driving the entrance and exit of the material banner when presented by the [ScaffoldMessenger]. + final Animation? animation; + + /// Called the first time that the material banner is visible within a [Scaffold] when presented by the [ScaffoldMessenger]. + final VoidCallback? onVisible; + + // API for ScaffoldMessengerState.showMaterialBanner(): + + /// Creates an animation controller useful for driving a material banner's entrance and exit animation. + static AnimationController createAnimationController({ required TickerProvider vsync }) { + return AnimationController( + duration: _materialBannerTransitionDuration, + debugLabel: 'MaterialBanner', + vsync: vsync, + ); + } + + /// Creates a copy of this material banner but with the animation replaced with the given animation. + /// + /// If the original material banner lacks a key, the newly created material banner will + /// use the given fallback key. + MaterialBanner withAnimation(Animation newAnimation, { Key? fallbackKey }) { + return MaterialBanner( + key: key ?? fallbackKey, + content: content, + contentTextStyle: contentTextStyle, + actions: actions, + leading: leading, + backgroundColor: backgroundColor, + padding: padding, + leadingPadding: leadingPadding, + forceActionsBelow: forceActionsBelow, + overflowAlignment: overflowAlignment, + animation: newAnimation, + onVisible: onVisible, + ); + } + + @override + State createState() => _MaterialBannerState(); +} + +class _MaterialBannerState extends State { + bool _wasVisible = false; + + @override + void initState() { + super.initState(); + widget.animation?.addStatusListener(_onAnimationStatusChanged); + } + + @override + void didUpdateWidget(MaterialBanner oldWidget) { + if (widget.animation != oldWidget.animation) { + oldWidget.animation?.removeStatusListener(_onAnimationStatusChanged); + widget.animation?.addStatusListener(_onAnimationStatusChanged); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.animation?.removeStatusListener(_onAnimationStatusChanged); + super.dispose(); + } + + void _onAnimationStatusChanged(AnimationStatus animationStatus) { + switch (animationStatus) { + case AnimationStatus.dismissed: + case AnimationStatus.forward: + case AnimationStatus.reverse: + break; + case AnimationStatus.completed: + if (widget.onVisible != null && !_wasVisible) { + widget.onVisible!(); + } + _wasVisible = true; + } + } + @override Widget build(BuildContext context) { - assert(actions.isNotEmpty); + assert(debugCheckHasMediaQuery(context)); + final MediaQueryData mediaQueryData = MediaQuery.of(context); + + assert(widget.actions.isNotEmpty); final ThemeData theme = Theme.of(context); final MaterialBannerThemeData bannerTheme = MaterialBannerTheme.of(context); - final bool isSingleRow = actions.length == 1 && !forceActionsBelow; - final EdgeInsetsGeometry padding = this.padding ?? bannerTheme.padding ?? (isSingleRow + final bool isSingleRow = widget.actions.length == 1 && !widget.forceActionsBelow; + final EdgeInsetsGeometry padding = widget.padding ?? bannerTheme.padding ?? (isSingleRow ? const EdgeInsetsDirectional.only(start: 16.0, top: 2.0) : const EdgeInsetsDirectional.only(start: 16.0, top: 24.0, end: 16.0, bottom: 4.0)); - final EdgeInsetsGeometry leadingPadding = this.leadingPadding + final EdgeInsetsGeometry leadingPadding = widget.leadingPadding ?? bannerTheme.leadingPadding ?? const EdgeInsetsDirectional.only(end: 16.0); @@ -158,36 +317,37 @@ class MaterialBanner extends StatelessWidget { constraints: const BoxConstraints(minHeight: 52.0), padding: const EdgeInsets.symmetric(horizontal: 8), child: OverflowBar( - overflowAlignment: overflowAlignment, + overflowAlignment: widget.overflowAlignment, spacing: 8, - children: actions, + children: widget.actions, ), ); - final Color backgroundColor = this.backgroundColor + final Color backgroundColor = widget.backgroundColor ?? bannerTheme.backgroundColor ?? theme.colorScheme.surface; - final TextStyle? textStyle = contentTextStyle + final TextStyle? textStyle = widget.contentTextStyle ?? bannerTheme.contentTextStyle ?? theme.textTheme.bodyText2; - return Container( + Widget materialBanner = Container( color: backgroundColor, child: Column( + mainAxisSize: MainAxisSize.min, children: [ Padding( padding: padding, child: Row( children: [ - if (leading != null) + if (widget.leading != null) Padding( padding: leadingPadding, - child: leading, + child: widget.leading, ), Expanded( child: DefaultTextStyle( style: textStyle!, - child: content, + child: widget.content, ), ), if (isSingleRow) @@ -201,5 +361,52 @@ class MaterialBanner extends StatelessWidget { ], ), ); + + // This provides a static banner for backwards compatibility. + if (widget.animation == null) + return materialBanner; + + final CurvedAnimation heightAnimation = CurvedAnimation(parent: widget.animation!, curve: _materialBannerHeightCurve); + final CurvedAnimation fadeOutAnimation = CurvedAnimation( + parent: widget.animation!, + curve: _materialBannerFadeOutCurve, + reverseCurve: const Threshold(0.0), + ); + + materialBanner = Semantics( + container: true, + liveRegion: true, + onDismiss: () { + ScaffoldMessenger.of(context).removeCurrentMaterialBanner(reason: MaterialBannerClosedReason.dismiss); + }, + child: mediaQueryData.accessibleNavigation + ? materialBanner + : FadeTransition( + opacity: fadeOutAnimation, + child: materialBanner, + ), + ); + + final Widget materialBannerTransition; + if (mediaQueryData.accessibleNavigation) { + materialBannerTransition = materialBanner; + } else { + materialBannerTransition = AnimatedBuilder( + animation: heightAnimation, + builder: (BuildContext context, Widget? child) { + return Align( + alignment: AlignmentDirectional.bottomStart, + heightFactor: heightAnimation.value, + child: child, + ); + }, + child: materialBanner, + ); + } + + return Hero( + child: ClipRect(child: materialBannerTransition), + tag: '', + ); } } diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 55eba6fe48..4c2f3c68bf 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -12,6 +12,7 @@ import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/widgets.dart'; import 'app_bar.dart'; +import 'banner.dart'; import 'bottom_sheet.dart'; import 'colors.dart'; import 'curves.dart'; @@ -49,6 +50,7 @@ enum _ScaffoldSlot { bodyScrim, bottomSheet, snackBar, + materialBanner, persistentFooter, bottomNavigationBar, floatingActionButton, @@ -256,6 +258,8 @@ class ScaffoldMessenger extends StatefulWidget { /// Typically obtained via [ScaffoldMessenger.of]. class ScaffoldMessengerState extends State with TickerProviderStateMixin { final LinkedHashSet _scaffolds = LinkedHashSet(); + final Queue> _materialBanners = Queue>(); + AnimationController? _materialBannerController; final Queue> _snackBars = Queue>(); AnimationController? _snackBarController; Timer? _snackBarTimer; @@ -280,8 +284,15 @@ class ScaffoldMessengerState extends State with TickerProvide void _register(ScaffoldState scaffold) { _scaffolds.add(scaffold); - if (_snackBars.isNotEmpty && _isRoot(scaffold)) { - scaffold._updateSnackBar(); + + if (_isRoot(scaffold)) { + if (_snackBars.isNotEmpty) { + scaffold._updateSnackBar(); + } + + if (_materialBanners.isNotEmpty) { + scaffold._updateMaterialBanner(); + } } } @@ -291,6 +302,24 @@ class ScaffoldMessengerState extends State with TickerProvide assert(removed); } + void _updateScaffolds() { + for (final ScaffoldState scaffold in _scaffolds) { + if (_isRoot(scaffold)) { + scaffold._updateSnackBar(); + scaffold._updateMaterialBanner(); + } + } + } + + // Nested Scaffolds are handled by the ScaffoldMessenger by only presenting a + // MaterialBanner or SnackBar in the root Scaffold of the nested set. + bool _isRoot(ScaffoldState scaffold) { + final ScaffoldState? parent = scaffold.context.findAncestorStateOfType(); + return parent == null || !_scaffolds.contains(parent); + } + + // SNACKBAR API + /// Shows a [SnackBar] across all registered [Scaffold]s. /// /// A scaffold can show at most one snack bar at a time. If this function is @@ -329,7 +358,7 @@ class ScaffoldMessengerState extends State with TickerProvide /// {@end-tool} ScaffoldFeatureController showSnackBar(SnackBar snackBar) { _snackBarController ??= SnackBar.createAnimationController(vsync: this) - ..addStatusListener(_handleStatusChanged); + ..addStatusListener(_handleSnackBarStatusChanged); if (_snackBars.isEmpty) { assert(_snackBarController!.isDismissed); _snackBarController!.forward(); @@ -354,7 +383,7 @@ class ScaffoldMessengerState extends State with TickerProvide return controller; } - void _handleStatusChanged(AnimationStatus status) { + void _handleSnackBarStatusChanged(AnimationStatus status) { switch (status) { case AnimationStatus.dismissed: assert(_snackBars.isNotEmpty); @@ -380,20 +409,6 @@ class ScaffoldMessengerState extends State with TickerProvide } } - void _updateScaffolds() { - for (final ScaffoldState scaffold in _scaffolds) { - if (_isRoot(scaffold)) - scaffold._updateSnackBar(); - } - } - - // Nested Scaffolds are handled by the ScaffoldMessenger by only presenting a - // SnackBar in the root Scaffold of the nested set. - bool _isRoot(ScaffoldState scaffold) { - final ScaffoldState? parent = scaffold.context.findAncestorStateOfType(); - return parent == null || !_scaffolds.contains(parent); - } - /// Removes the current [SnackBar] (if any) immediately from registered /// [Scaffold]s. /// @@ -445,6 +460,145 @@ class ScaffoldMessengerState extends State with TickerProvide hideCurrentSnackBar(); } + // MATERIAL BANNER API + + /// Shows a [MaterialBanner] across all registered [Scaffold]s. + /// + /// A scaffold can show at most one material banner at a time. If this function is + /// called while another material banner is already visible, the given material banner + /// will be added to a queue and displayed after the earlier material banners have + /// closed. + /// + /// To remove the [MaterialBanner] with an exit animation, use [hideCurrentMaterialBanner] + /// or call [ScaffoldFeatureController.close] on the returned + /// [ScaffoldFeatureController]. To remove a [MaterialBanner] suddenly (without an + /// animation), use [removeCurrentMaterialBanner]. + /// + /// See [ScaffoldMessenger.of] for information about how to obtain the + /// [ScaffoldMessengerState]. + /// + /// {@tool dartpad --template=stateless_widget_scaffold_center} + /// + /// Here is an example of showing a [MaterialBanner] when the user presses a button. + /// + /// ```dart + /// Widget build(BuildContext context) { + /// return OutlinedButton( + /// onPressed: () { + /// ScaffoldMessenger.of(context).showMaterialBanner( + /// const MaterialBanner( + /// content: Text('This is a MaterialBanner'), + /// actions: [ + /// TextButton( + /// child: Text('DISMISS'), + /// onPressed: null, + /// ), + /// ], + /// ), + /// ); + /// }, + /// child: const Text('Show MaterialBanner'), + /// ); + /// } + /// ``` + /// {@end-tool} + ScaffoldFeatureController showMaterialBanner(MaterialBanner materialBanner) { + _materialBannerController ??= MaterialBanner.createAnimationController(vsync: this) + ..addStatusListener(_handleMaterialBannerStatusChanged); + if (_materialBanners.isEmpty) { + assert(_materialBannerController!.isDismissed); + _materialBannerController!.forward(); + } + late ScaffoldFeatureController controller; + controller = ScaffoldFeatureController._( + // We provide a fallback key so that if back-to-back material banners happen to + // match in structure, material ink splashes and highlights don't survive + // from one to the next. + materialBanner.withAnimation(_materialBannerController!, fallbackKey: UniqueKey()), + Completer(), + () { + assert(_materialBanners.first == controller); + hideCurrentMaterialBanner(reason: MaterialBannerClosedReason.hide); + }, + null, // MaterialBanner doesn't use a builder function so setState() wouldn't rebuild it + ); + setState(() { + _materialBanners.addLast(controller); + }); + _updateScaffolds(); + return controller; + } + + void _handleMaterialBannerStatusChanged(AnimationStatus status) { + switch (status) { + case AnimationStatus.dismissed: + assert(_materialBanners.isNotEmpty); + setState(() { + _materialBanners.removeFirst(); + }); + _updateScaffolds(); + if (_materialBanners.isNotEmpty) { + _materialBannerController!.forward(); + } + break; + case AnimationStatus.completed: + _updateScaffolds(); + break; + case AnimationStatus.forward: + break; + case AnimationStatus.reverse: + break; + } + } + + /// Removes the current [MaterialBanner] (if any) immediately from registered + /// [Scaffold]s. + /// + /// The removed material banner does not run its normal exit animation. If there are + /// any queued material banners, they begin their entrance animation immediately. + void removeCurrentMaterialBanner({ MaterialBannerClosedReason reason = MaterialBannerClosedReason.remove }) { + assert(reason != null); + if (_materialBanners.isEmpty) + return; + final Completer completer = _materialBanners.first._completer; + if (!completer.isCompleted) + completer.complete(reason); + + // This will trigger the animation's status callback. + _materialBannerController!.value = 0.0; + } + + /// Removes the current [MaterialBanner] by running its normal exit animation. + /// + /// The closed completer is called after the animation is complete. + void hideCurrentMaterialBanner({ MaterialBannerClosedReason reason = MaterialBannerClosedReason.hide }) { + assert(reason != null); + if (_materialBanners.isEmpty || _materialBannerController!.status == AnimationStatus.dismissed) + return; + final Completer completer = _materialBanners.first._completer; + if (_accessibleNavigation!) { + _materialBannerController!.value = 0.0; + completer.complete(reason); + } else { + _materialBannerController!.reverse().then((void value) { + assert(mounted); + if (!completer.isCompleted) + completer.complete(reason); + }); + } + } + + /// Removes all the materialBanners currently in queue by clearing the queue + /// and running normal exit animation on the current materialBanner. + void clearMaterialBanners() { + if (_materialBanners.isEmpty || _materialBannerController!.status == AnimationStatus.dismissed) + return; + final ScaffoldFeatureController currentMaterialBanner = _materialBanners.first; + _materialBanners.clear(); + _materialBanners.add(currentMaterialBanner); + hideCurrentMaterialBanner(); + } + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); @@ -522,6 +676,7 @@ class ScaffoldPrelayoutGeometry { required this.minViewPadding, required this.scaffoldSize, required this.snackBarSize, + required this.materialBannerSize, required this.textDirection, }); @@ -605,6 +760,11 @@ class ScaffoldPrelayoutGeometry { /// If the [Scaffold] is not showing a [SnackBar], this will be [Size.zero]. final Size snackBarSize; + /// The [Size] of the [Scaffold]'s [MaterialBanner]. + /// + /// If the [Scaffold] is not showing a [MaterialBanner], this will be [Size.zero]. + final Size materialBannerSize; + /// The [TextDirection] of the [Scaffold]'s [BuildContext]. final TextDirection textDirection; } @@ -967,6 +1127,11 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); } + Size materialBannerSize = Size.zero; + if (hasChild(_ScaffoldSlot.materialBanner)) { + materialBannerSize = layoutChild(_ScaffoldSlot.materialBanner, fullWidthConstraints); + } + if (hasChild(_ScaffoldSlot.bottomSheet)) { final BoxConstraints bottomSheetConstraints = BoxConstraints( maxWidth: fullWidthConstraints.maxWidth, @@ -992,6 +1157,7 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { minInsets: minInsets, scaffoldSize: size, snackBarSize: snackBarSize, + materialBannerSize: materialBannerSize, textDirection: textDirection, minViewPadding: minViewPadding, ); @@ -1034,6 +1200,17 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { positionChild(_ScaffoldSlot.snackBar, Offset(xOffset, snackBarYOffsetBase - snackBarSize.height)); } + if (hasChild(_ScaffoldSlot.materialBanner)) { + if (materialBannerSize == Size.zero) { + materialBannerSize = layoutChild( + _ScaffoldSlot.materialBanner, + fullWidthConstraints, + ); + } + + positionChild(_ScaffoldSlot.materialBanner, Offset(0.0, appBarHeight)); + } + if (hasChild(_ScaffoldSlot.statusBar)) { layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top)); positionChild(_ScaffoldSlot.statusBar, Offset.zero); @@ -2194,12 +2371,14 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto _endDrawerKey.currentState?.open(); } + // Used for both the snackbar and material banner APIs + ScaffoldMessengerState? _scaffoldMessenger; + bool? _accessibleNavigation; + // SNACKBAR API final Queue> _snackBars = Queue>(); AnimationController? _snackBarController; Timer? _snackBarTimer; - bool? _accessibleNavigation; - ScaffoldMessengerState? _scaffoldMessenger; /// [ScaffoldMessengerState.showSnackBar] shows a [SnackBar] at the bottom of /// the scaffold. This method should not be used, and will be deprecated in @@ -2401,11 +2580,34 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto // This is used to update the _messengerSnackBar by the ScaffoldMessenger. void _updateSnackBar() { - setState(() { - _messengerSnackBar = _scaffoldMessenger!._snackBars.isNotEmpty - ? _scaffoldMessenger!._snackBars.first - : null; - }); + final ScaffoldFeatureController? messengerSnackBar = _scaffoldMessenger!._snackBars.isNotEmpty + ? _scaffoldMessenger!._snackBars.first + : null; + + if (_messengerSnackBar != messengerSnackBar) { + setState(() { + _messengerSnackBar = messengerSnackBar; + }); + } + } + + // MATERIAL BANNER API + + // The _messengerMaterialBanner represents the current MaterialBanner being managed by + // the ScaffoldMessenger, instead of the Scaffold. + ScaffoldFeatureController? _messengerMaterialBanner; + + // This is used to update the _messengerMaterialBanner by the ScaffoldMessenger. + void _updateMaterialBanner() { + final ScaffoldFeatureController? messengerMaterialBanner = _scaffoldMessenger!._materialBanners.isNotEmpty + ? _scaffoldMessenger!._materialBanners.first + : null; + + if (_messengerMaterialBanner != messengerMaterialBanner) { + setState(() { + _messengerMaterialBanner = messengerMaterialBanner; + }); + } } // PERSISTENT BOTTOM SHEET API @@ -3136,6 +3338,20 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto ); } + // MaterialBanner set by ScaffoldMessenger + if (_messengerMaterialBanner != null) { + _addIfNonNull( + children, + _messengerMaterialBanner?._widget, + _ScaffoldSlot.materialBanner, + removeLeftPadding: false, + removeTopPadding: widget.appBar != null, + removeRightPadding: false, + removeBottomPadding: true, + maintainBottomViewPadding: !_resizeToAvoidBottomInset, + ); + } + if (widget.persistentFooterButtons != null) { _addIfNonNull( children, @@ -3288,7 +3504,7 @@ class ScaffoldFeatureController { /// Completes when the feature controlled by this object is no longer visible. Future get closed => _completer.future; - /// Remove the feature (e.g., bottom sheet or snack bar) from the scaffold. + /// Remove the feature (e.g., bottom sheet, snack bar, or material banner) from the scaffold. final VoidCallback close; /// Mark the feature (e.g., bottom sheet or snack bar) as needing to rebuild. diff --git a/packages/flutter/test/material/banner_test.dart b/packages/flutter/test/material/banner_test.dart index 204e8f9670..86bdacfcb2 100644 --- a/packages/flutter/test/material/banner_test.dart +++ b/packages/flutter/test/material/banner_test.dart @@ -28,6 +28,44 @@ void main() { expect(container.color, color); }); + testWidgets('Custom background color respected when presented by ScaffoldMessenger', (WidgetTester tester) async { + const Color color = Colors.pink; + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + backgroundColor: color, + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + expect(_getContainerFromText(tester, contentText).color, color); + }); + testWidgets('Custom content TextStyle respected', (WidgetTester tester) async { const String contentText = 'Content'; const TextStyle contentTextStyle = TextStyle(color: Colors.pink); @@ -50,6 +88,45 @@ void main() { expect(content.text.style, contentTextStyle); }); + testWidgets('Custom content TextStyle respected when presented by ScaffoldMessenger', (WidgetTester tester) async { + const TextStyle contentTextStyle = TextStyle(color: Colors.pink); + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + contentTextStyle: contentTextStyle, + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(content.text.style, contentTextStyle); + }); + testWidgets('Actions laid out below content if more than one action', (WidgetTester tester) async { const String contentText = 'Content'; @@ -77,6 +154,49 @@ void main() { expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); }); + testWidgets('Actions laid out below content if more than one action when presented by ScaffoldMessenger', (WidgetTester tester) async { + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }); + testWidgets('Actions laid out beside content if only one action', (WidgetTester tester) async { const String contentText = 'Content'; @@ -100,6 +220,236 @@ void main() { expect(contentBottomLeft.dx, lessThan(actionsTopRight.dx)); }); + testWidgets('Actions laid out beside content if only one action when presented by ScaffoldMessenger', (WidgetTester tester) async { + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, greaterThan(actionsTopRight.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopRight.dx)); + }); + + testWidgets('MaterialBanner control test', (WidgetTester tester) async { + const String helloMaterialBanner = 'Hello MaterialBanner'; + const Key tapTarget = Key('tap-target'); + const Key dismissTarget = Key('dismiss-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(helloMaterialBanner), + actions: [ + TextButton( + key: dismissTarget, + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + expect(find.text(helloMaterialBanner), findsNothing); + await tester.tap(find.byKey(tapTarget)); + expect(find.text(helloMaterialBanner), findsNothing); + await tester.pump(); // schedule animation + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 0.75s // animation last frame; two second timer starts here + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text(helloMaterialBanner), findsOneWidget); + await tester.tap(find.byKey(dismissTarget)); + await tester.pump(); // begin animation + expect(find.text(helloMaterialBanner), findsOneWidget); // frame 0 of dismiss animation + await tester.pumpAndSettle(); // 3.75s // last frame of animation, material banner removed from build + expect(find.text(helloMaterialBanner), findsNothing); + }); + + testWidgets('MaterialBanner twice test', (WidgetTester tester) async { + int materialBannerCount = 0; + const Key tapTarget = Key('tap-target'); + const Key dismissTarget = Key('dismiss-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + materialBannerCount += 1; + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: Text('banner$materialBannerCount'), + actions: [ + TextButton( + key: dismissTarget, + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + await tester.tap(find.byKey(tapTarget)); // queue banner1 + await tester.tap(find.byKey(tapTarget)); // queue banner2 + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + await tester.pump(); // schedule animation for banner1 + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 0.75s // animation last frame + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 1.50s + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 2.25s + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.tap(find.byKey(dismissTarget)); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsOneWidget); + expect(find.text('banner2'), findsNothing); + await tester.pump(const Duration(milliseconds: 750)); // 3.75s // last frame of animation, material banner removed from build, new material banner put in its place + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(); // begin animation + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 4.50s // animation last frame + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 5.25s + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 6.00s + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.tap(find.byKey(dismissTarget)); // reverse animation is scheduled + await tester.pump(); // begin animation + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 750)); // 7.50s // last frame of animation, material banner removed from build + expect(find.text('banner1'), findsNothing); + expect(find.text('banner2'), findsNothing); + }); + + testWidgets('ScaffoldMessenger does not duplicate a MaterialBanner when presenting a SnackBar.', (WidgetTester tester) async { + const Key materialBannerTapTarget = Key('materialbanner-tap-target'); + const Key snackBarTapTarget = Key('snackbar-tap-target'); + const String snackBarText = 'SnackBar'; + const String materialBannerText = 'MaterialBanner'; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: [ + GestureDetector( + key: snackBarTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text(snackBarText), + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ), + GestureDetector( + key: materialBannerTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(materialBannerText), + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ), + ], + ); + }, + ), + ), + )); + await tester.tap(find.byKey(snackBarTapTarget)); + await tester.tap(find.byKey(materialBannerTapTarget)); + await tester.pumpAndSettle(); + + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(materialBannerText), findsOneWidget); + }); + // Regression test for https://github.com/flutter/flutter/issues/39574 testWidgets('Single action laid out beside content but aligned to the trailing edge', (WidgetTester tester) async { await tester.pumpWidget( @@ -121,12 +471,50 @@ void main() { expect(actionsTopRight.dx + 8, bannerTopRight.dx); // actions OverflowBar is padded by 8 }); + // Regression test for https://github.com/flutter/flutter/issues/39574 + testWidgets('Single action laid out beside content but aligned to the trailing edge when presented by ScaffoldMessenger', (WidgetTester tester) async { + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text('Content'), + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset actionsTopRight = tester.getTopRight(find.byType(OverflowBar)); + final Offset bannerTopRight = tester.getTopRight(find.byType(MaterialBanner)); + expect(actionsTopRight.dx + 8, bannerTopRight.dx); // actions OverflowBar is padded by 8 + }); + // Regression test for https://github.com/flutter/flutter/issues/39574 testWidgets('Single action laid out beside content but aligned to the trailing edge - RTL', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Directionality( - textDirection: TextDirection.rtl, + textDirection: TextDirection.rtl, child: MaterialBanner( content: const Text('Content'), actions: [ @@ -145,6 +533,46 @@ void main() { expect(actionsTopLeft.dx - 8, bannerTopLeft.dx); // actions OverflowBar is padded by 8 }); + testWidgets('Single action laid out beside content but aligned to the trailing edge when presented by ScaffoldMessenger - RTL', (WidgetTester tester) async { + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text('Content'), + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + final Offset bannerTopLeft = tester.getTopLeft(find.byType(MaterialBanner)); + expect(actionsTopLeft.dx - 8, bannerTopLeft.dx); // actions OverflowBar is padded by 8 + }); + testWidgets('Actions laid out below content if forced override', (WidgetTester tester) async { const String contentText = 'Content'; @@ -169,10 +597,49 @@ void main() { expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); }); + testWidgets('Actions laid out below content if forced override when presented by ScaffoldMessenger', (WidgetTester tester) async { + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + forceActionsBelow: true, + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Offset contentBottomLeft = tester.getBottomLeft(find.text(contentText)); + final Offset actionsTopLeft = tester.getTopLeft(find.byType(OverflowBar)); + expect(contentBottomLeft.dy, lessThan(actionsTopLeft.dy)); + expect(contentBottomLeft.dx, lessThan(actionsTopLeft.dx)); + }); + testWidgets('Action widgets layout', (WidgetTester tester) async { // This regression test ensures that the action widgets layout matches what // it was, before ButtonBar was replaced by OverflowBar. - Widget buildFrame(int actionCount, TextDirection textDirection) { return MaterialApp( home: Directionality( @@ -194,7 +661,6 @@ void main() { final Finder action0 = find.byKey(const ValueKey(0)); final Finder action1 = find.byKey(const ValueKey(1)); final Finder action2 = find.byKey(const ValueKey(2)); - // The action coordinates that follow were obtained by running // the test code, before ButtonBar was replaced by OverflowBar. @@ -215,10 +681,95 @@ void main() { expect(tester.getTopLeft(action2), const Offset(8, 130)); }); - testWidgets('Action widgets layout with overflow', (WidgetTester tester) async { + testWidgets('Action widgets layout when presented by ScaffoldMessenger', (WidgetTester tester) async { // This regression test ensures that the action widgets layout matches what // it was, before ButtonBar was replaced by OverflowBar. + Widget buildFrame(int actionCount, TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List.generate(actionCount, (int index) { + if (index == 0) + return SizedBox( + width: 64, + height: 48, + key: ValueKey(index), + child: GestureDetector( + key: const ValueKey('dismiss-target'), + onTap: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + + return SizedBox( + width: 64, + height: 48, + key: ValueKey(index), + ); + }), + )); + }, + ); + } + ), + ), + ), + ); + } + + final Finder tapTarget = find.byKey(const ValueKey('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey('dismiss-target')); + final Finder action0 = find.byKey(const ValueKey(0)); + final Finder action1 = find.byKey(const ValueKey(1)); + final Finder action2 = find.byKey(const ValueKey(2)); + + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + await tester.pumpWidget(buildFrame(1, TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(728, 28)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(1, TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(8, 28)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(3, TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(584, 130)); + expect(tester.getTopLeft(action1), const Offset(656, 130)); + expect(tester.getTopLeft(action2), const Offset(728, 130)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(3, TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(action0), const Offset(152, 130)); + expect(tester.getTopLeft(action1), const Offset(80, 130)); + expect(tester.getTopLeft(action2), const Offset(8, 130)); + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); + + testWidgets('Action widgets layout with overflow', (WidgetTester tester) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. const int actionCount = 4; Widget buildFrame(TextDirection textDirection) { return MaterialApp( @@ -237,7 +788,6 @@ void main() { ), ); } - // The action coordinates that follow were obtained by running // the test code, before ButtonBar was replaced by OverflowBar. @@ -252,6 +802,76 @@ void main() { } }); + testWidgets('Action widgets layout with overflow when presented by ScaffoldMessenger', (WidgetTester tester) async { + // This regression test ensures that the action widgets layout matches what + // it was, before ButtonBar was replaced by OverflowBar. + + const int actionCount = 4; + Widget buildFrame(TextDirection textDirection) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const SizedBox(width: 100, height: 100), + actions: List.generate(actionCount, (int index) { + if (index == 0) + return SizedBox( + width: 200, + height: 10, + key: ValueKey(index), + child: GestureDetector( + key: const ValueKey('dismiss-target'), + onTap: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + + return SizedBox( + width: 200, + height: 10, + key: ValueKey(index), + ); + }), + )); + }, + ); + } + ), + ), + ), + ); + } + + // The action coordinates that follow were obtained by running + // the test code, before ButtonBar was replaced by OverflowBar. + + final Finder tapTarget = find.byKey(const ValueKey('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey('dismiss-target')); + + await tester.pumpWidget(buildFrame(TextDirection.ltr)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (int index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(592, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.rtl)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (int index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(8, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); + testWidgets('[overflowAlignment] test', (WidgetTester tester) async { const int actionCount = 4; Widget buildFrame(TextDirection textDirection, OverflowBarAlignment overflowAlignment) { @@ -288,12 +908,90 @@ void main() { expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(592, 134.0 + index * 10)); } }); + + testWidgets('[overflowAlignment] test when presented by ScaffoldMessenger', (WidgetTester tester) async { + const int actionCount = 4; + Widget buildFrame(TextDirection textDirection, OverflowBarAlignment overflowAlignment) { + return MaterialApp( + home: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: const ValueKey('tap-target'), + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + overflowAlignment: overflowAlignment, + content: const SizedBox(width: 100, height: 100), + actions: List.generate(actionCount, (int index) { + if (index == 0) + return SizedBox( + width: 200, + height: 10, + key: ValueKey(index), + child: GestureDetector( + key: const ValueKey('dismiss-target'), + onTap: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ); + + return SizedBox( + width: 200, + height: 10, + key: ValueKey(index), + ); + }), + )); + }, + ); + } + ), + ), + ), + ); + } + + final Finder tapTarget = find.byKey(const ValueKey('tap-target')); + final Finder dismissTarget = find.byKey(const ValueKey('dismiss-target')); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.start)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (int index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(8, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.center)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (int index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(300, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildFrame(TextDirection.ltr, OverflowBarAlignment.end)); + await tester.tap(tapTarget); + await tester.pumpAndSettle(); + for (int index = 0; index < actionCount; index += 1) { + expect(tester.getTopLeft(find.byKey(ValueKey(index))), Offset(592, 134.0 + index * 10)); + } + await tester.tap(dismissTarget); + await tester.pumpAndSettle(); + }); } Container _getContainerFromBanner(WidgetTester tester) { return tester.widget(find.descendant(of: find.byType(MaterialBanner), matching: find.byType(Container)).first); } +Container _getContainerFromText(WidgetTester tester, String text) { + return tester.widget(find.widgetWithText(Container, text).first); +} + RenderParagraph _getTextRenderObjectFromDialog(WidgetTester tester, String text) { return tester.element(find.descendant(of: find.byType(MaterialBanner), matching: find.text(text))).renderObject! as RenderParagraph; } diff --git a/packages/flutter/test/material/banner_theme_test.dart b/packages/flutter/test/material/banner_theme_test.dart index 73fa91d525..62fdb9ccaa 100644 --- a/packages/flutter/test/material/banner_theme_test.dart +++ b/packages/flutter/test/material/banner_theme_test.dart @@ -64,7 +64,47 @@ void main() { ), )); - final Container container = _getContainerFromBanner(tester); + final Container container = _getContainerFromText(tester, contentText); + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(container.color, const Color(0xffffffff)); + // Default value for ThemeData.typography is Typography.material2014() + expect(content.text.style, Typography.material2014().englishLike.bodyText2!.merge(Typography.material2014().black.bodyText2)); + }); + + testWidgets('Passing no MaterialBannerThemeData returns defaults when presented by ScaffoldMessenger', (WidgetTester tester) async { + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + actions: [ + TextButton( + child: const Text('Action'), + onPressed: () { }, + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Container container = _getContainerFromText(tester, contentText); final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); expect(container.color, const Color(0xffffffff)); // Default value for ThemeData.typography is Typography.material2014() @@ -90,7 +130,57 @@ void main() { ), )); - final Container container = _getContainerFromBanner(tester); + final Container container = _getContainerFromText(tester, contentText); + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(container.color, bannerTheme.backgroundColor); + expect(content.text.style, bannerTheme.contentTextStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset containerTopLeft = tester.getTopLeft(_containerFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - containerTopLeft.dy, 24); + expect(contentTopLeft.dx - containerTopLeft.dx, 41); + expect(leadingTopLeft.dy - containerTopLeft.dy, 19); + expect(leadingTopLeft.dx - containerTopLeft.dx, 11); + }); + + testWidgets('MaterialBanner uses values from MaterialBannerThemeData when presented by ScaffoldMessenger', (WidgetTester tester) async { + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + leading: const Icon(Icons.ac_unit), + content: const Text(contentText), + actions: [ + TextButton( + child: const Text('Action'), + onPressed: () { }, + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Container container = _getContainerFromText(tester, contentText); final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); expect(container.color, bannerTheme.backgroundColor); expect(content.text.style, bannerTheme.contentTextStyle); @@ -129,7 +219,63 @@ void main() { ), )); - final Container container = _getContainerFromBanner(tester); + final Container container = _getContainerFromText(tester, contentText); + final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); + expect(container.color, backgroundColor); + expect(content.text.style, textStyle); + + final Offset contentTopLeft = tester.getTopLeft(_textFinder(contentText)); + final Offset containerTopLeft = tester.getTopLeft(_containerFinder()); + final Offset leadingTopLeft = tester.getTopLeft(find.byIcon(Icons.ac_unit)); + expect(contentTopLeft.dy - containerTopLeft.dy, 29); + expect(contentTopLeft.dx - containerTopLeft.dx, 58); + expect(leadingTopLeft.dy - containerTopLeft.dy, 24); + expect(leadingTopLeft.dx - containerTopLeft.dx, 22); + }); + + testWidgets('MaterialBanner widget properties take priority over theme when presented by ScaffoldMessenger', (WidgetTester tester) async { + const Color backgroundColor = Colors.purple; + const TextStyle textStyle = TextStyle(color: Colors.green); + final MaterialBannerThemeData bannerTheme = _bannerTheme(); + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(bannerTheme: bannerTheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + backgroundColor: backgroundColor, + leading: const Icon(Icons.ac_unit), + contentTextStyle: textStyle, + content: const Text(contentText), + padding: const EdgeInsets.all(10), + leadingPadding: const EdgeInsets.all(12), + actions: [ + TextButton( + child: const Text('Action'), + onPressed: () { }, + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Container container = _getContainerFromText(tester, contentText); final RenderParagraph content = _getTextRenderObjectFromDialog(tester, contentText); expect(container.color, backgroundColor); expect(content.text.style, textStyle); @@ -145,11 +291,12 @@ void main() { testWidgets('MaterialBanner uses color scheme when necessary', (WidgetTester tester) async { final ColorScheme colorScheme = const ColorScheme.light().copyWith(surface: Colors.purple); + const String contentText = 'Content'; await tester.pumpWidget(MaterialApp( theme: ThemeData(colorScheme: colorScheme), home: Scaffold( body: MaterialBanner( - content: const Text('Content'), + content: const Text(contentText), actions: [ TextButton( child: const Text('Action'), @@ -160,7 +307,46 @@ void main() { ), )); - final Container container = _getContainerFromBanner(tester); + final Container container = _getContainerFromText(tester, contentText); + expect(container.color, colorScheme.surface); + }); + + testWidgets('MaterialBanner uses color scheme when necessary when presented by ScaffoldMessenger', (WidgetTester tester) async { + final ColorScheme colorScheme = const ColorScheme.light().copyWith(surface: Colors.purple); + const String contentText = 'Content'; + const Key tapTarget = Key('tap-target'); + await tester.pumpWidget(MaterialApp( + theme: ThemeData(colorScheme: colorScheme), + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return GestureDetector( + key: tapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(contentText), + actions: [ + TextButton( + child: const Text('Action'), + onPressed: () { }, + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ); + }, + ), + ), + )); + await tester.tap(find.byKey(tapTarget)); + await tester.pumpAndSettle(); + + final Container container = _getContainerFromText(tester, contentText); expect(container.color, colorScheme.surface); }); } @@ -174,8 +360,8 @@ MaterialBannerThemeData _bannerTheme() { ); } -Container _getContainerFromBanner(WidgetTester tester) { - return tester.widget(_containerFinder()); +Container _getContainerFromText(WidgetTester tester, String text) { + return tester.widget(find.widgetWithText(Container, text).first); } Finder _containerFinder() { diff --git a/packages/flutter/test/material/snack_bar_test.dart b/packages/flutter/test/material/snack_bar_test.dart index fd055ef53f..2d70b5d199 100644 --- a/packages/flutter/test/material/snack_bar_test.dart +++ b/packages/flutter/test/material/snack_bar_test.dart @@ -2282,6 +2282,63 @@ void main() { await expectLater(find.byType(MaterialApp), matchesGoldenFile('snack_bar.goldenTest.workWithBottomSheet.png')); }); + testWidgets('ScaffoldMessenger does not duplicate a SnackBar when presenting a MaterialBanner.', (WidgetTester tester) async { + const Key materialBannerTapTarget = Key('materialbanner-tap-target'); + const Key snackBarTapTarget = Key('snackbar-tap-target'); + const String snackBarText = 'SnackBar'; + const String materialBannerText = 'MaterialBanner'; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return Column( + children: [ + GestureDetector( + key: snackBarTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text(snackBarText), + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ), + GestureDetector( + key: materialBannerTapTarget, + onTap: () { + ScaffoldMessenger.of(context).showMaterialBanner(MaterialBanner( + content: const Text(materialBannerText), + actions: [ + TextButton( + child: const Text('DISMISS'), + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + ), + ], + )); + }, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + height: 100.0, + width: 100.0, + ), + ), + ], + ); + }, + ), + ), + )); + await tester.tap(find.byKey(snackBarTapTarget)); + await tester.tap(find.byKey(materialBannerTapTarget)); + await tester.pumpAndSettle(); + + expect(find.text(snackBarText), findsOneWidget); + expect(find.text(materialBannerText), findsOneWidget); + }); + testWidgets('ScaffoldMessenger presents SnackBars to only the root Scaffold when Scaffolds are nested.', (WidgetTester tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold(