Commit e3d05449 authored by Hans Muller's avatar Hans Muller Committed by GitHub

Report why a snackbar was closed (#6996)

parent 1d4292f7
......@@ -537,7 +537,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
// SNACKBAR API
Queue<ScaffoldFeatureController<SnackBar, Null>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, Null>>();
Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, SnackBarClosedReason>>();
AnimationController _snackBarController;
Timer _snackBarTimer;
......@@ -554,23 +554,23 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
/// [removeCurrentSnackBar].
///
/// See [Scaffold.of] for information about how to obtain the [ScaffoldState].
ScaffoldFeatureController<SnackBar, Null> showSnackBar(SnackBar snackbar) {
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSnackBar(SnackBar snackbar) {
_snackBarController ??= SnackBar.createAnimationController(vsync: this)
..addStatusListener(_handleSnackBarStatusChange);
if (_snackBars.isEmpty) {
assert(_snackBarController.isDismissed);
_snackBarController.forward();
}
ScaffoldFeatureController<SnackBar, Null> controller;
controller = new ScaffoldFeatureController<SnackBar, Null>._(
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> controller;
controller = new ScaffoldFeatureController<SnackBar, SnackBarClosedReason>._(
// We provide a fallback key so that if back-to-back snackbars happen to
// match in structure, material ink splashes and highlights don't survive
// from one to the next.
snackbar.withAnimation(_snackBarController, fallbackKey: new UniqueKey()),
new Completer<Null>(),
new Completer<SnackBarClosedReason>(),
() {
assert(_snackBars.first == controller);
_hideSnackBar();
hideCurrentSnackBar(reason: SnackBarClosedReason.hide);
},
null // SnackBar doesn't use a builder function so setState() wouldn't rebuild it
);
......@@ -606,27 +606,31 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
///
/// The removed snack bar does not run its normal exit animation. If there are
/// any queued snack bars, they begin their entrance animation immediately.
void removeCurrentSnackBar() {
void removeCurrentSnackBar({ SnackBarClosedReason reason: SnackBarClosedReason.remove }) {
assert(reason != null);
if (_snackBars.isEmpty)
return;
Completer<Null> completer = _snackBars.first._completer;
final Completer<SnackBarClosedReason> completer = _snackBars.first._completer;
if (!completer.isCompleted)
completer.complete();
completer.complete(reason);
_snackBarTimer?.cancel();
_snackBarTimer = null;
_snackBarController.value = 0.0;
}
void _hideSnackBar() {
assert(_snackBarController.status == AnimationStatus.forward ||
_snackBarController.status == AnimationStatus.completed);
_snackBars.first._completer.complete();
/// Removes the current [SnackBar] by running its normal exit animation.
void hideCurrentSnackBar({ SnackBarClosedReason reason: SnackBarClosedReason.hide }) {
assert(reason != null);
if (_snackBars.isEmpty || _snackBarController.status == AnimationStatus.dismissed)
return;
final Completer<SnackBarClosedReason> completer = _snackBars.first._completer;
if (!completer.isCompleted)
completer.complete(reason);
_snackBarController.reverse();
_snackBarTimer?.cancel();
_snackBarTimer = null;
}
// PERSISTENT BOTTOM SHEET API
final List<_PersistentBottomSheet> _dismissedBottomSheets = <_PersistentBottomSheet>[];
......@@ -922,7 +926,11 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
final ModalRoute<dynamic> route = ModalRoute.of(context);
if (route == null || route.isCurrent) {
if (_snackBarController.isCompleted && _snackBarTimer == null)
_snackBarTimer = new Timer(_snackBars.first._widget.duration, _hideSnackBar);
_snackBarTimer = new Timer(_snackBars.first._widget.duration, () {
assert(_snackBarController.status == AnimationStatus.forward ||
_snackBarController.status == AnimationStatus.completed);
hideCurrentSnackBar(reason: SnackBarClosedReason.timeout);
});
} else {
_snackBarTimer?.cancel();
_snackBarTimer = null;
......
......@@ -30,6 +30,40 @@ const Duration _kSnackBarDisplayDuration = const Duration(milliseconds: 1500);
const Curve _snackBarHeightCurve = Curves.fastOutSlowIn;
const Curve _snackBarFadeCurve = const Interval(0.72, 1.0, curve: Curves.fastOutSlowIn);
/// Specify how a [SnackBar] was closed.
///
/// The [showSnackBar] function returns a [ScaffoldFeatureController]. The value
/// of the controller's closed property is a Future that resolves to a
/// SnackBarClosedReason. Applications that need to know how a snackbar
/// was closed can use this value.
///
/// Example:
///
/// ```dart
/// Scaffold.of(context).showSnackBar(
/// new SnackBar( ... )
/// ).closed.then((SnackBarClosedReason reason) {
/// ...
/// });
/// ```
enum SnackBarClosedReason {
/// The snack bar was closed after the user tapped a [SnackBarAction].
action,
/// The snack bar was closed by a user's swipe.
swipe,
/// The snack bar was closed by the [ScaffoldFeatureController] close callback
/// or by calling [hideCurrentSnackBar] directly.
hide,
/// The snack bar was closed by an call to [removeCurrentSnackBar].
remove,
/// The snack bar was closed because its timer expired.
timeout,
}
/// A button for a [SnackBar], known as an "action".
///
/// Snack bar actions are always enabled. If you want to disable a snack bar
......@@ -77,6 +111,7 @@ class _SnackBarActionState extends State<SnackBarAction> {
_haveTriggeredAction = true;
});
config.onPressed();
Scaffold.of(context).hideCurrentSnackBar(reason: SnackBarClosedReason.action);
}
@override
......@@ -201,7 +236,7 @@ class SnackBar extends StatelessWidget {
direction: DismissDirection.down,
resizeDuration: null,
onDismissed: (DismissDirection direction) {
Scaffold.of(context).removeCurrentSnackBar();
Scaffold.of(context).removeCurrentSnackBar(reason: SnackBarClosedReason.swipe);
},
child: new Material(
elevation: 6,
......
......@@ -130,7 +130,7 @@ void main() {
int snackBarCount = 0;
Key tapTarget = new Key('tap-target');
int time;
ScaffoldFeatureController<SnackBar, Null> lastController;
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> lastController;
await tester.pumpWidget(new MaterialApp(
home: new Scaffold(
body: new Builder(
......@@ -158,7 +158,7 @@ void main() {
expect(find.text('bar2'), findsNothing);
time = 1000;
await tester.tap(find.byKey(tapTarget)); // queue bar1
ScaffoldFeatureController<SnackBar, Null> firstController = lastController;
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> firstController = lastController;
time = 2;
await tester.tap(find.byKey(tapTarget)); // queue bar2
expect(find.text('bar1'), findsNothing);
......@@ -334,4 +334,79 @@ void main() {
expect(actionTextBottomLeft.x - textBottomRight.x, 24.0);
expect(snackBarBottomRight.x - actionTextBottomRight.x, 24.0);
});
testWidgets('SnackBarClosedReason', (WidgetTester tester) async {
final GlobalKey<ScaffoldState> scaffoldKey = new GlobalKey<ScaffoldState>();
bool actionPressed = false;
SnackBarClosedReason closedReason;
await tester.pumpWidget(new MaterialApp(
home: new Scaffold(
key: scaffoldKey,
body: new Builder(
builder: (BuildContext context) {
return new GestureDetector(
onTap: () {
Scaffold.of(context).showSnackBar(new SnackBar(
content: new Text('snack'),
duration: new Duration(seconds: 2),
action: new SnackBarAction(
label: 'ACTION',
onPressed: () {
actionPressed = true;
}
),
)).closed.then((SnackBarClosedReason reason) {
closedReason = reason;
});
},
child: new Text('X')
);
},
)
)
));
// Pop up the snack bar and then press its action button.
await tester.tap(find.text('X'));
await tester.pump(); // start animation
await tester.pump(const Duration(milliseconds: 750));
expect(actionPressed, isFalse);
await tester.tap(find.text('ACTION'));
expect(actionPressed, isTrue);
await tester.pump(const Duration(seconds: 1));
expect(closedReason, equals(SnackBarClosedReason.action));
// Pop up the snack bar and then swipe downwards to dismiss it.
await tester.tap(find.text('X'));
await tester.pump(const Duration(milliseconds: 750));
await tester.pump(const Duration(milliseconds: 750));
await tester.scroll(find.text('snack'), new Offset(0.0, 50.0));
await tester.pump();
expect(closedReason, equals(SnackBarClosedReason.swipe));
// Pop up the snack bar and then remove it.
await tester.tap(find.text('X'));
await tester.pump(const Duration(milliseconds: 750));
scaffoldKey.currentState.removeCurrentSnackBar();
await tester.pump(const Duration(seconds: 1));
expect(closedReason, equals(SnackBarClosedReason.remove));
// Pop up the snack bar and then hide it.
await tester.tap(find.text('X'));
await tester.pump(const Duration(milliseconds: 750));
scaffoldKey.currentState.hideCurrentSnackBar();
await tester.pump(const Duration(seconds: 1));
expect(closedReason, equals(SnackBarClosedReason.hide));
// Pop up the snack bar and then let it time out.
await tester.tap(find.text('X'));
await tester.pump(new Duration(milliseconds: 750));
await tester.pump(new Duration(milliseconds: 750));
await tester.pump(new Duration(milliseconds: 1500));
await tester.pump(); // begin animation
await tester.pump(new Duration(milliseconds: 750));
expect(closedReason, equals(SnackBarClosedReason.timeout));
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment