diff --git a/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart b/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart new file mode 100644 index 00000000000..894d727b55b --- /dev/null +++ b/examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart @@ -0,0 +1,90 @@ +// 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 'package:flutter/material.dart'; + +/// Flutter code sample for [SnackBar]. + +void main() => runApp(const SnackBarApp()); + +class SnackBarApp extends StatelessWidget { + const SnackBarApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: SnackBarExample(), + ); + } +} + +enum AnimationStyles { defaultStyle, custom, none } +const List<(AnimationStyles, String)> animationStyleSegments = <(AnimationStyles, String)>[ + (AnimationStyles.defaultStyle, 'Default'), + (AnimationStyles.custom, 'Custom'), + (AnimationStyles.none, 'None'), +]; + +class SnackBarExample extends StatefulWidget { + const SnackBarExample({super.key}); + + @override + State createState() => _SnackBarExampleState(); +} + +class _SnackBarExampleState extends State { + final Set _animationStyleSelection = {AnimationStyles.defaultStyle}; + AnimationStyle? _animationStyle; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SnackBar Sample')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SegmentedButton( + selected: _animationStyleSelection, + onSelectionChanged: (Set styles) { + setState(() { + _animationStyle = switch (styles.first) { + AnimationStyles.defaultStyle => null, + AnimationStyles.custom => AnimationStyle( + duration: const Duration(seconds: 3), + reverseDuration: const Duration(seconds: 1), + ), + AnimationStyles.none => AnimationStyle.noAnimation, + }; + }); + }, + segments: animationStyleSegments + .map>(((AnimationStyles, String) shirt) { + return ButtonSegment(value: shirt.$1, label: Text(shirt.$2)); + }) + .toList(), + ), + const SizedBox(height: 10), + Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + snackBarAnimationStyle: _animationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + } + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart b/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart new file mode 100644 index 00000000000..8133cc5d872 --- /dev/null +++ b/examples/api/test/material/scaffold/scaffold_messenger_state.show_snack_bar.2_test.dart @@ -0,0 +1,72 @@ +// 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 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('ScaffoldMessenger showSnackBar animation can be customized using AnimationStyle', + (WidgetTester tester) async { + await tester.pumpWidget( + const example.SnackBarApp(), + ); + + // Tap the button to show the SnackBar with default animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); // Advance the animation by 250ms. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + + // Select custom animation style. + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar with custom animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1500)); // Advance the animation by 125ms. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 1500)); // Advance the animation by 125ms. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // Advance the animation by 1sec. + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + + // Select no animation style. + await tester.tap(find.text('None')); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar with no animation style. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + expect(find.text('I am a snack bar.'), findsNothing); + }); +} diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index be58374512e..3d8680e634f 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -290,13 +290,38 @@ class ScaffoldMessengerState extends State with TickerProvide /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart ** /// {@end-tool} /// - ScaffoldFeatureController showSnackBar(SnackBar snackBar) { + /// If [AnimationStyle.duration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar show animation duration. + /// Otherwise, defaults to 250ms. + /// + /// If [AnimationStyle.reverseDuration] is provided in the [snackBarAnimationStyle] + /// parameter, it will be used to override the snackbar hide animation duration. + /// Otherwise, defaults to 250ms. + /// + /// To disable the snackbar animation, use [AnimationStyle.noAnimation]. + /// + /// {@tool dartpad} + /// This sample showcases how to override [SnackBar] show and hide animation + /// duration using [AnimationStyle] in [ScaffoldMessengerState.showSnackBar]. + /// + /// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.2.dart ** + /// {@end-tool} + /// + ScaffoldFeatureController showSnackBar( + SnackBar snackBar, + { AnimationStyle? snackBarAnimationStyle } + ) { assert( _scaffolds.isNotEmpty, 'ScaffoldMessenger.showSnackBar was called, but there are currently no ' 'descendant Scaffolds to present to.', ); - _snackBarController ??= SnackBar.createAnimationController(vsync: this) + _didUpdateAnimationStyle(snackBarAnimationStyle); + _snackBarController ??= SnackBar.createAnimationController( + duration: snackBarAnimationStyle?.duration, + reverseDuration: snackBarAnimationStyle?.reverseDuration, + vsync: this, + ) ..addStatusListener(_handleSnackBarStatusChanged); if (_snackBars.isEmpty) { assert(_snackBarController!.isDismissed); @@ -355,6 +380,16 @@ class ScaffoldMessengerState extends State with TickerProvide return controller; } + void _didUpdateAnimationStyle(AnimationStyle? snackBarAnimationStyle) { + if (snackBarAnimationStyle != null) { + if (_snackBarController?.duration != snackBarAnimationStyle.duration || + _snackBarController?.reverseDuration != snackBarAnimationStyle.reverseDuration) { + _snackBarController?.dispose(); + _snackBarController = null; + } + } + } + void _handleSnackBarStatusChanged(AnimationStatus status) { switch (status) { case AnimationStatus.dismissed: diff --git a/packages/flutter/lib/src/material/snack_bar.dart b/packages/flutter/lib/src/material/snack_bar.dart index 4b602e29dbf..b1329718ad9 100644 --- a/packages/flutter/lib/src/material/snack_bar.dart +++ b/packages/flutter/lib/src/material/snack_bar.dart @@ -476,9 +476,14 @@ class SnackBar extends StatefulWidget { // API for ScaffoldMessengerState.showSnackBar(): /// Creates an animation controller useful for driving a snack bar's entrance and exit animation. - static AnimationController createAnimationController({ required TickerProvider vsync }) { + static AnimationController createAnimationController({ + required TickerProvider vsync, + Duration? duration, + Duration? reverseDuration, + }) { return AnimationController( - duration: _snackBarTransitionDuration, + duration: duration ?? _snackBarTransitionDuration, + reverseDuration: reverseDuration, debugLabel: 'SnackBar', vsync: vsync, ); diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 7fc2ff30146..e219fa0dac6 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -2868,6 +2868,241 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('ScaffoldMessenger showSnackBar default animatiom', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + )); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); + + testWidgets('ScaffoldMessenger showSnackBar animation can be customized', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + snackBarAnimationStyle: AnimationStyle( + duration: const Duration(milliseconds: 1200), + reverseDuration: const Duration(milliseconds: 600), + ), + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + )); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(602.6, 0.1)); + + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 600)); // Advance the animation by 600ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ns. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 300)); // Advance the animation by 300ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); + + testWidgets('Updated snackBarAnimationStyle updates snack bar animation', (WidgetTester tester) async { + Widget buildSnackBar(AnimationStyle snackBarAnimationStyle) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + snackBarAnimationStyle: snackBarAnimationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ); + } + + // Test custom animation style. + await tester.pumpWidget(buildSnackBar(AnimationStyle( + duration: const Duration(milliseconds: 800), + reverseDuration: const Duration(milliseconds: 400), + ))); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); // Advance the animation by 400ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + + // Test no animation style. + await tester.pumpWidget(buildSnackBar(AnimationStyle.noAnimation)); + await tester.pumpAndSettle(); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + // The SnackBar is dismissed. + expect(find.text('I am a snack bar.'), findsNothing); + }); + + testWidgets('snackBarAnimationStyle with only reverseDuration uses default forward duration', + (WidgetTester tester) async { + Widget buildSnackBar(AnimationStyle snackBarAnimationStyle) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('I am a snack bar.'), + showCloseIcon: true, + ), + snackBarAnimationStyle: snackBarAnimationStyle, + ); + }, + child: const Text('Show SnackBar'), + ); + }, + ), + ), + ); + } + + // Test custom animation style with only reverseDuration. + await tester.pumpWidget(buildSnackBar(AnimationStyle( + reverseDuration: const Duration(milliseconds: 400), + ))); + + // Tap the button to show the SnackBar. + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + // Advance the animation by 1/2 of the default forward duration. + await tester.pump(const Duration(milliseconds: 125)); // Advance the animation by 125ms. + + // The SnackBar is fully visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(566, 0.1)); + + // Tap the close button to dismiss the SnackBar. + await tester.tap(find.byType(IconButton)); + await tester.pump(); + // Advance the animation by 1/2 of the reverse duration. + await tester.pump(const Duration(milliseconds: 200)); + + // The SnackBar is partially visible. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(576.7, 0.1)); + + // Advance the animation by 1/2 of the reverse duration. + await tester.pump(const Duration(milliseconds: 200)); // Advance the animation by 200ms. + + // The SnackBar is dismissed. + expect(tester.getTopLeft(find.text('I am a snack bar.')).dy, closeTo(614, 0.1)); + }); } class _GeometryListener extends StatefulWidget {