From 7619c6865553e7b47528c398a88b3695618b9658 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Tue, 22 Jan 2019 16:39:09 -0800 Subject: [PATCH] Add Dismissible.confirmDismiss callback (#26901) --- .../flutter/lib/src/widgets/dismissible.dart | 40 +++++- .../flutter/test/material/dialog_test.dart | 131 ++++++++++++++++++ .../test/widgets/dismissible_test.dart | 68 ++++++++- 3 files changed, 232 insertions(+), 7 deletions(-) diff --git a/packages/flutter/lib/src/widgets/dismissible.dart b/packages/flutter/lib/src/widgets/dismissible.dart index 283dce786d..e61224c9bc 100644 --- a/packages/flutter/lib/src/widgets/dismissible.dart +++ b/packages/flutter/lib/src/widgets/dismissible.dart @@ -24,6 +24,12 @@ const double _kDismissThreshold = 0.4; /// Used by [Dismissible.onDismissed]. typedef DismissDirectionCallback = void Function(DismissDirection direction); +/// Signature used by [Dismissible] to give the application an opportunity to +/// confirm or veto a dismiss gesture. +/// +/// Used by [Dismissible.confirmDismiss]. +typedef ConfirmDismissCallback = Future Function(DismissDirection direction); + /// The direction in which a [Dismissible] can be dismissed. enum DismissDirection { /// The [Dismissible] can be dismissed by dragging either up or down. @@ -77,6 +83,7 @@ class Dismissible extends StatefulWidget { @required this.child, this.background, this.secondaryBackground, + this.confirmDismiss, this.onResize, this.onDismissed, this.direction = DismissDirection.horizontal, @@ -105,6 +112,15 @@ class Dismissible extends StatefulWidget { /// has also been specified. final Widget secondaryBackground; + /// Gives the app an opportunity to confirm or veto a pending dismissal. + /// + /// If the returned Future completes true, then this widget will be + /// dismissed, otherwise it will be moved back to its original location. + /// + /// If the returned Future completes to false or null the [onResize] + /// and [onDismissed] callbacks will not run. + final ConfirmDismissCallback confirmDismiss; + /// Called when the widget changes size (i.e., when contracting before being dismissed). final VoidCallback onResize; @@ -386,11 +402,11 @@ class _DismissibleState extends State with TickerProviderStateMixin return _FlingGestureKind.reverse; } - void _handleDragEnd(DragEndDetails details) { + Future _handleDragEnd(DragEndDetails details) async { if (!_isActive || _moveController.isAnimating) return; _dragUnderway = false; - if (_moveController.isCompleted) { + if (_moveController.isCompleted && await _confirmStartResizeAnimation() == true) { _startResizeAnimation(); return; } @@ -424,12 +440,25 @@ class _DismissibleState extends State with TickerProviderStateMixin } } - void _handleDismissStatusChanged(AnimationStatus status) { - if (status == AnimationStatus.completed && !_dragUnderway) - _startResizeAnimation(); + Future _handleDismissStatusChanged(AnimationStatus status) async { + if (status == AnimationStatus.completed && !_dragUnderway) { + if (await _confirmStartResizeAnimation() == true) + _startResizeAnimation(); + else + _moveController.reverse(); + } updateKeepAlive(); } + Future _confirmStartResizeAnimation() async { + if (widget.confirmDismiss != null) { + final DismissDirection direction = _dismissDirection; + assert(direction != null); + return widget.confirmDismiss(direction); + } + return true; + } + void _startResizeAnimation() { assert(_moveController != null); assert(_moveController.isCompleted); @@ -550,4 +579,3 @@ class _DismissibleState extends State with TickerProviderStateMixin ); } } - diff --git a/packages/flutter/test/material/dialog_test.dart b/packages/flutter/test/material/dialog_test.dart index feb4d5d324..1bf4f3fe93 100644 --- a/packages/flutter/test/material/dialog_test.dart +++ b/packages/flutter/test/material/dialog_test.dart @@ -476,4 +476,135 @@ void main() { semantics.dispose(); }); + + testWidgets('Dismissable.confirmDismiss defers to an AlertDialog', (WidgetTester tester) async { + final GlobalKey _scaffoldKey = GlobalKey(); + final List dismissedItems = []; + + // Dismiss is confirmed IFF confirmDismiss() returns true. + Future confirmDismiss (DismissDirection dismissDirection) { + return showDialog( + context: _scaffoldKey.currentContext, + barrierDismissible: true, // showDialog() returns null if tapped outside the dialog + builder: (BuildContext context) { + return AlertDialog( + actions: [ + FlatButton( + child: const Text('TRUE'), + onPressed: () { + Navigator.pop(context, true); // showDialog() returns true + }, + ), + FlatButton( + child: const Text('FALSE'), + onPressed: () { + Navigator.pop(context, false); // showDialog() returns false + }, + ), + ], + ); + }, + ); + } + + Widget buildDismissibleItem(int item, StateSetter setState) { + return Dismissible( + key: ValueKey(item), + confirmDismiss: confirmDismiss, + onDismissed: (DismissDirection direction) { + setState(() { + expect(dismissedItems.contains(item), isFalse); + dismissedItems.add(item); + }); + }, + child: SizedBox( + height: 100.0, + child: Text(item.toString()), + ), + ); + } + + Widget buildFrame() { + return MaterialApp( + home: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Scaffold( + key: _scaffoldKey, + body: Padding( + padding: const EdgeInsets.all(16.0), + child: ListView( + itemExtent: 100.0, + children: [0, 1, 2, 3, 4] + .where((int i) => !dismissedItems.contains(i)) + .map((int item) => buildDismissibleItem(item, setState)).toList(), + ), + ), + ); + }, + ), + ); + } + + Future dismissItem(WidgetTester tester, int item) async { + await tester.fling(find.text(item.toString()), const Offset(300.0, 0.0), 1000.0); // fling to the right + await tester.pump(); // start the slide + await tester.pump(const Duration(seconds: 1)); // finish the slide and start shrinking... + await tester.pump(); // first frame of shrinking animation + await tester.pump(const Duration(seconds: 1)); // finish the shrinking and call the callback... + await tester.pump(); // rebuild after the callback removes the entry + } + + // Dismiss item 0 is confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, isEmpty); + await dismissItem(tester, 0); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('TRUE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, [0]); + expect(find.text('0'), findsNothing); + + // Dismiss item 1 is not confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, [0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('FALSE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, [0]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + + // Dismiss item 1 is not confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, [0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + expect(find.text('FALSE'), findsOneWidget); + expect(find.text('TRUE'), findsOneWidget); + await tester.tapAt(Offset.zero); // Tap outside of the AlertDialog + await tester.pumpAndSettle(); + expect(dismissedItems, [0]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + + // Dismiss item 1 is confirmed via the AlertDialog + await tester.pumpWidget(buildFrame()); + expect(dismissedItems, [0]); + await dismissItem(tester, 1); // Causes the AlertDialog to appear per confirmDismiss + await tester.pumpAndSettle(); + await tester.tap(find.text('TRUE')); // AlertDialog action + await tester.pumpAndSettle(); + expect(find.text('TRUE'), findsNothing); // Dialog was dismissed + expect(find.text('FALSE'), findsNothing); + expect(dismissedItems, [0, 1]); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsNothing); + }); } diff --git a/packages/flutter/test/widgets/dismissible_test.dart b/packages/flutter/test/widgets/dismissible_test.dart index 9623121b93..d5523419b9 100644 --- a/packages/flutter/test/widgets/dismissible_test.dart +++ b/packages/flutter/test/widgets/dismissible_test.dart @@ -15,7 +15,11 @@ List dismissedItems = []; Widget background; const double crossAxisEndOffset = 0.5; -Widget buildTest({ double startToEndThreshold, TextDirection textDirection = TextDirection.ltr }) { +Widget buildTest({ + double startToEndThreshold, + TextDirection textDirection = TextDirection.ltr, + Future Function(BuildContext context, DismissDirection direction) confirmDismiss, +}) { return Directionality( textDirection: textDirection, child: StatefulBuilder( @@ -25,6 +29,9 @@ Widget buildTest({ double startToEndThreshold, TextDirection textDirection = Tex dragStartBehavior: DragStartBehavior.down, key: ValueKey(item), direction: dismissDirection, + confirmDismiss: confirmDismiss == null ? null : (DismissDirection direction) { + return confirmDismiss(context, direction); + }, onDismissed: (DismissDirection direction) { setState(() { reportedDismissDirection = direction; @@ -660,4 +667,63 @@ void main() { expect(find.text('1'), findsOneWidget); expect(dismissedItems, isEmpty); }); + + testWidgets('confirmDismiss returns values: true, false, null', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.horizontal; + DismissDirection confirmDismissDirection; + + Widget buildFrame(bool confirmDismissValue) { + return buildTest( + confirmDismiss: (BuildContext context, DismissDirection dismissDirection) { + confirmDismissDirection = dismissDirection; + return Future.value(confirmDismissValue); + } + ); + } + + // Dismiss is confirmed IFF confirmDismiss() returns true. + await tester.pumpWidget(buildFrame(true)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right, mechanism: flingElement); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + expect(reportedDismissDirection, DismissDirection.startToEnd); + expect(confirmDismissDirection, DismissDirection.startToEnd); + + await dismissItem(tester, 1, gestureDirection: AxisDirection.left, mechanism: flingElement); + expect(find.text('1'), findsNothing); + expect(dismissedItems, equals([0, 1])); + expect(reportedDismissDirection, DismissDirection.endToStart); + expect(confirmDismissDirection, DismissDirection.endToStart); + + // Dismiss is not confirmed if confirmDismiss() returns false + dismissedItems = []; + await tester.pumpWidget(buildFrame(false)); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right, mechanism: flingElement); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + expect(confirmDismissDirection, DismissDirection.startToEnd); + + await dismissItem(tester, 1, gestureDirection: AxisDirection.left, mechanism: flingElement); + expect(find.text('1'), findsOneWidget); + expect(dismissedItems, isEmpty); + expect(confirmDismissDirection, DismissDirection.endToStart); + + // Dismiss is not confirmed if confirmDismiss() returns null + dismissedItems = []; + await tester.pumpWidget(buildFrame(null)); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right, mechanism: flingElement); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + expect(confirmDismissDirection, DismissDirection.startToEnd); + + await dismissItem(tester, 1, gestureDirection: AxisDirection.left, mechanism: flingElement); + expect(find.text('1'), findsOneWidget); + expect(dismissedItems, isEmpty); + expect(confirmDismissDirection, DismissDirection.endToStart); + }); }