Add AnimationStyle to showSnackBar (#142825)

fixes [`showSnackBar` is always replacing `animation` parameter of `SnackBar`](https://github.com/flutter/flutter/issues/141646)

### Code sample preview

![Screenshot 2024-02-02 at 21 10 57](https://github.com/flutter/flutter/assets/48603081/66d808f0-d638-4561-b9a4-96d1b93938f4)
This commit is contained in:
Taha Tesser 2024-02-06 20:54:41 +02:00 committed by GitHub
parent e6ba809b8a
commit 0cc381da19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 441 additions and 4 deletions

View file

@ -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<SnackBarExample> createState() => _SnackBarExampleState();
}
class _SnackBarExampleState extends State<SnackBarExample> {
Set<AnimationStyles> _animationStyleSelection = <AnimationStyles>{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: <Widget>[
SegmentedButton<AnimationStyles>(
selected: _animationStyleSelection,
onSelectionChanged: (Set<AnimationStyles> 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<ButtonSegment<AnimationStyles>>(((AnimationStyles, String) shirt) {
return ButtonSegment<AnimationStyles>(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'),
);
}
),
],
),
),
);
}
}

View file

@ -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);
});
}

View file

@ -290,13 +290,38 @@ class ScaffoldMessengerState extends State<ScaffoldMessenger> with TickerProvide
/// ** See code in examples/api/lib/material/scaffold/scaffold_messenger_state.show_snack_bar.1.dart **
/// {@end-tool}
///
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> 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<SnackBar, SnackBarClosedReason> 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<ScaffoldMessenger> 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:

View file

@ -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,
);

View file

@ -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 {