From 2db0c25f823f6ebf3864da92d485d712bfb61c84 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Wed, 22 Nov 2017 15:20:15 -0800 Subject: [PATCH] Dismissible RTL (#13137) Fix the dismissible demo in the gallery (make it actuall update when you pick something from its menu; give it a better affordance for resetting once you've dismissed everything). Improve some docs. Fix various flinging bugs with dismissible. Add tests for those cases. Add a feature to flutter_test to support a drag-then-fling gesture (used by the flinging tests). --- .../lib/demo/material/leave_behind_demo.dart | 43 ++- .../flutter/lib/src/widgets/dismissible.dart | 174 +++++++--- .../test/widgets/dismissible_test.dart | 316 +++++++++++++++--- packages/flutter_test/lib/src/controller.dart | 40 ++- 4 files changed, 455 insertions(+), 118 deletions(-) diff --git a/examples/flutter_gallery/lib/demo/material/leave_behind_demo.dart b/examples/flutter_gallery/lib/demo/material/leave_behind_demo.dart index b44312d4c65..5b191694e5a 100644 --- a/examples/flutter_gallery/lib/demo/material/leave_behind_demo.dart +++ b/examples/flutter_gallery/lib/demo/material/leave_behind_demo.dart @@ -60,20 +60,22 @@ class LeaveBehindDemoState extends State { } void handleDemoAction(LeaveBehindDemoAction action) { - switch (action) { - case LeaveBehindDemoAction.reset: - initListItems(); - break; - case LeaveBehindDemoAction.horizontalSwipe: - _dismissDirection = DismissDirection.horizontal; - break; - case LeaveBehindDemoAction.leftSwipe: - _dismissDirection = DismissDirection.endToStart; - break; - case LeaveBehindDemoAction.rightSwipe: - _dismissDirection = DismissDirection.startToEnd; - break; - } + setState(() { + switch (action) { + case LeaveBehindDemoAction.reset: + initListItems(); + break; + case LeaveBehindDemoAction.horizontalSwipe: + _dismissDirection = DismissDirection.horizontal; + break; + case LeaveBehindDemoAction.leftSwipe: + _dismissDirection = DismissDirection.endToStart; + break; + case LeaveBehindDemoAction.rightSwipe: + _dismissDirection = DismissDirection.startToEnd; + break; + } + }); } void handleUndo(LeaveBehindItem item) { @@ -161,9 +163,16 @@ class LeaveBehindDemoState extends State { ) ] ), - body: new ListView( - children: leaveBehindItems.map(buildItem).toList() - ) + body: leaveBehindItems.isEmpty + ? new Center( + child: new RaisedButton( + onPressed: () => handleDemoAction(LeaveBehindDemoAction.reset), + child: const Text('Reset the list'), + ), + ) + : new ListView( + children: leaveBehindItems.map(buildItem).toList() + ), ); } } diff --git a/packages/flutter/lib/src/widgets/dismissible.dart b/packages/flutter/lib/src/widgets/dismissible.dart index 491f2c2c303..8cd7372a301 100644 --- a/packages/flutter/lib/src/widgets/dismissible.dart +++ b/packages/flutter/lib/src/widgets/dismissible.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'automatic_keep_alive.dart'; import 'basic.dart'; +import 'debug.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'ticker_provider.dart'; @@ -114,12 +115,21 @@ class Dismissible extends StatefulWidget { /// immediately after the the widget is dismissed. final Duration resizeDuration; - /// The offset threshold the item has to be dragged in order to be considered dismissed. + /// The offset threshold the item has to be dragged in order to be considered + /// dismissed. /// - /// Represented as a fraction, e.g. if it is 0.4, then the item has to be dragged at least - /// 40% towards one direction to be considered dismissed. Clients can define different - /// thresholds for each dismiss direction. This allows for use cases where item can be - /// dismissed to end but not to start. + /// Represented as a fraction, e.g. if it is 0.4 (the default), then the item + /// has to be dragged at least 40% towards one direction to be considered + /// dismissed. Clients can define different thresholds for each dismiss + /// direction. + /// + /// Flinging is treated as being equivalent to dragging almost to 1.0, so + /// flinging can dismiss an item past any threshold less than 1.0. + /// + /// See also [direction], which controls the directions in which the items can + /// be dismissed. Setting a threshold of 1.0 (or greater) prevents a drag in + /// the given [DismissDirection] even if it would be allowed by the + /// [direction] property. final Map dismissThresholds; @override @@ -165,6 +175,8 @@ class _DismissibleClipper extends CustomClipper { } } +enum _FlingGestureKind { none, forward, reverse } + class _DismissibleState extends State with TickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override void initState() { @@ -200,15 +212,23 @@ class _DismissibleState extends State with TickerProviderStateMixin || widget.direction == DismissDirection.startToEnd; } - DismissDirection get _dismissDirection { - if (_directionIsXAxis) - return _dragExtent > 0 ? DismissDirection.startToEnd : DismissDirection.endToStart; - return _dragExtent > 0 ? DismissDirection.down : DismissDirection.up; + DismissDirection _extentToDirection(double extent) { + if (extent == 0.0) + return null; + if (_directionIsXAxis) { + switch (Directionality.of(context)) { + case TextDirection.rtl: + return extent < 0 ? DismissDirection.startToEnd : DismissDirection.endToStart; + case TextDirection.ltr: + return extent > 0 ? DismissDirection.startToEnd : DismissDirection.endToStart; + } + assert(false); + return null; + } + return extent > 0 ? DismissDirection.down : DismissDirection.up; } - double get _dismissThreshold { - return widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold; - } + DismissDirection get _dismissDirection => _extentToDirection(_dragExtent); bool get _isActive { return _dragUnderway || _moveController.isAnimating; @@ -246,16 +266,40 @@ class _DismissibleState extends State with TickerProviderStateMixin break; case DismissDirection.up: - case DismissDirection.endToStart: if (_dragExtent + delta < 0) _dragExtent += delta; break; case DismissDirection.down: - case DismissDirection.startToEnd: if (_dragExtent + delta > 0) _dragExtent += delta; break; + + case DismissDirection.endToStart: + switch (Directionality.of(context)) { + case TextDirection.rtl: + if (_dragExtent + delta > 0) + _dragExtent += delta; + break; + case TextDirection.ltr: + if (_dragExtent + delta < 0) + _dragExtent += delta; + break; + } + break; + + case DismissDirection.startToEnd: + switch (Directionality.of(context)) { + case TextDirection.rtl: + if (_dragExtent + delta < 0) + _dragExtent += delta; + break; + case TextDirection.ltr: + if (_dragExtent + delta > 0) + _dragExtent += delta; + break; + } + break; } if (oldDragExtent.sign != _dragExtent.sign) { setState(() { @@ -275,35 +319,35 @@ class _DismissibleState extends State with TickerProviderStateMixin ).animate(_moveController); } - bool _isFlingGesture(Velocity velocity) { - // Cannot fling an item if it cannot be dismissed by drag. - if (_dismissThreshold >= 1.0) - return false; + _FlingGestureKind _describeFlingGesture(Velocity velocity) { + assert(widget.direction != null); + if (_dragExtent == 0.0) { + // If it was a fling, then it was a fling that was let loose at the exact + // middle of the range (i.e. when there's no displacement). In that case, + // we assume that the user meant to fling it back to the center, as + // opposed to having wanted to drag it out one way, then fling it past the + // center and into and out the other side. + return _FlingGestureKind.none; + } final double vx = velocity.pixelsPerSecond.dx; final double vy = velocity.pixelsPerSecond.dy; + DismissDirection flingDirection; + // Verify that the fling is in the generally right direction and fast enough. if (_directionIsXAxis) { - if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta) - return false; - switch (widget.direction) { - case DismissDirection.horizontal: - return vx.abs() > _kMinFlingVelocity; - case DismissDirection.endToStart: - return -vx > _kMinFlingVelocity; - default: - return vx > _kMinFlingVelocity; - } + if (vx.abs() - vy.abs() < _kMinFlingVelocityDelta || vx.abs() < _kMinFlingVelocity) + return _FlingGestureKind.none; + assert(vx != 0.0); + flingDirection = _extentToDirection(vx); } else { - if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta) - return false; - switch (widget.direction) { - case DismissDirection.vertical: - return vy.abs() > _kMinFlingVelocity; - case DismissDirection.up: - return -vy > _kMinFlingVelocity; - default: - return vy > _kMinFlingVelocity; - } + if (vy.abs() - vx.abs() < _kMinFlingVelocityDelta || vy.abs() < _kMinFlingVelocity) + return _FlingGestureKind.none; + assert(vy != 0.0); + flingDirection = _extentToDirection(vy); } + assert(_dismissDirection != null); + if (flingDirection == _dismissDirection) + return _FlingGestureKind.forward; + return _FlingGestureKind.reverse; } void _handleDragEnd(DragEndDetails details) { @@ -312,14 +356,35 @@ class _DismissibleState extends State with TickerProviderStateMixin _dragUnderway = false; if (_moveController.isCompleted) { _startResizeAnimation(); - } else if (_isFlingGesture(details.velocity)) { - final double flingVelocity = _directionIsXAxis ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy; - _dragExtent = flingVelocity.sign; - _moveController.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale); - } else if (_moveController.value > _dismissThreshold) { - _moveController.forward(); - } else { - _moveController.reverse(); + return; + } + final double flingVelocity = _directionIsXAxis ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy; + switch (_describeFlingGesture(details.velocity)) { + case _FlingGestureKind.forward: + assert(_dragExtent != 0.0); + assert(!_moveController.isDismissed); + if ((widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold) >= 1.0) { + _moveController.reverse(); + break; + } + _dragExtent = flingVelocity.sign; + _moveController.fling(velocity: flingVelocity.abs() * _kFlingVelocityScale); + break; + case _FlingGestureKind.reverse: + assert(_dragExtent != 0.0); + assert(!_moveController.isDismissed); + _dragExtent = flingVelocity.sign; + _moveController.fling(velocity: -flingVelocity.abs() * _kFlingVelocityScale); + break; + case _FlingGestureKind.none: + if (!_moveController.isDismissed) { // we already know it's not completed, we check that above + if (_moveController.value > (widget.dismissThresholds[_dismissDirection] ?? _kDismissThreshold)) { + _moveController.forward(); + } else { + _moveController.reverse(); + } + } + break; } } @@ -335,8 +400,11 @@ class _DismissibleState extends State with TickerProviderStateMixin assert(_resizeController == null); assert(_sizePriorToCollapse == null); if (widget.resizeDuration == null) { - if (widget.onDismissed != null) - widget.onDismissed(_dismissDirection); + if (widget.onDismissed != null) { + final DismissDirection direction = _dismissDirection; + assert(direction != null); + widget.onDismissed(direction); + } } else { _resizeController = new AnimationController(duration: widget.resizeDuration, vsync: this) ..addListener(_handleResizeProgressChanged) @@ -357,8 +425,11 @@ class _DismissibleState extends State with TickerProviderStateMixin void _handleResizeProgressChanged() { if (_resizeController.isCompleted) { - if (widget.onDismissed != null) - widget.onDismissed(_dismissDirection); + if (widget.onDismissed != null) { + final DismissDirection direction = _dismissDirection; + assert(direction != null); + widget.onDismissed(direction); + } } else { if (widget.onResize != null) widget.onResize(); @@ -368,6 +439,9 @@ class _DismissibleState extends State with TickerProviderStateMixin @override Widget build(BuildContext context) { super.build(context); // See AutomaticKeepAliveClientMixin. + + assert(!_directionIsXAxis || debugCheckHasDirectionality(context)); + Widget background = widget.background; if (widget.secondaryBackground != null) { final DismissDirection direction = _dismissDirection; diff --git a/packages/flutter/test/widgets/dismissible_test.dart b/packages/flutter/test/widgets/dismissible_test.dart index 74e5b8f47f9..600b60ada9c 100644 --- a/packages/flutter/test/widgets/dismissible_test.dart +++ b/packages/flutter/test/widgets/dismissible_test.dart @@ -2,9 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; const double itemExtent = 100.0; Axis scrollDirection = Axis.vertical; @@ -13,9 +14,9 @@ DismissDirection reportedDismissDirection; List dismissedItems = []; Widget background; -Widget buildTest({ double startToEndThreshold }) { +Widget buildTest({ double startToEndThreshold, TextDirection textDirection: TextDirection.ltr }) { return new Directionality( - textDirection: TextDirection.ltr, + textDirection: textDirection, child: new StatefulBuilder( builder: (BuildContext context, StateSetter setState) { Widget buildDismissibleItem(int item) { @@ -44,17 +45,14 @@ Widget buildTest({ double startToEndThreshold }) { ); } - return new Directionality( - textDirection: TextDirection.ltr, - child: new Container( - padding: const EdgeInsets.all(10.0), - child: new ListView( - scrollDirection: scrollDirection, - itemExtent: itemExtent, - children: [0, 1, 2, 3, 4] - .where((int i) => !dismissedItems.contains(i)) - .map(buildDismissibleItem).toList(), - ), + return new Container( + padding: const EdgeInsets.all(10.0), + child: new ListView( + scrollDirection: scrollDirection, + itemExtent: itemExtent, + children: [0, 1, 2, 3, 4] + .where((int i) => !dismissedItems.contains(i)) + .map(buildDismissibleItem).toList(), ), ); }, @@ -62,32 +60,30 @@ Widget buildTest({ double startToEndThreshold }) { ); } -Future dismissElement(WidgetTester tester, Finder finder, { DismissDirection gestureDirection }) async { - assert(tester.any(finder)); - assert(gestureDirection != DismissDirection.horizontal); - assert(gestureDirection != DismissDirection.vertical); +typedef Future DismissMethod(WidgetTester tester, Finder finder, { @required AxisDirection gestureDirection }); +Future dismissElement(WidgetTester tester, Finder finder, { @required AxisDirection gestureDirection }) async { Offset downLocation; Offset upLocation; switch (gestureDirection) { - case DismissDirection.endToStart: + case AxisDirection.left: // getTopRight() returns a point that's just beyond itemWidget's right // edge and outside the Dismissible event listener's bounds. downLocation = tester.getTopRight(finder) + const Offset(-0.1, 0.0); upLocation = tester.getTopLeft(finder); break; - case DismissDirection.startToEnd: + case AxisDirection.right: // we do the same thing here to keep the test symmetric downLocation = tester.getTopLeft(finder) + const Offset(0.1, 0.0); upLocation = tester.getTopRight(finder); break; - case DismissDirection.up: + case AxisDirection.up: // getBottomLeft() returns a point that's just below itemWidget's bottom // edge and outside the Dismissible event listener's bounds. downLocation = tester.getBottomLeft(finder) + const Offset(0.0, -0.1); upLocation = tester.getTopLeft(finder); break; - case DismissDirection.down: + case AxisDirection.down: // again with doing the same here for symmetry downLocation = tester.getTopLeft(finder) + const Offset(0.1, 0.0); upLocation = tester.getBottomLeft(finder); @@ -96,19 +92,49 @@ Future dismissElement(WidgetTester tester, Finder finder, { DismissDirecti fail('unsupported gestureDirection'); } - final TestGesture gesture = await tester.startGesture(downLocation, pointer: 5); + final TestGesture gesture = await tester.startGesture(downLocation); await gesture.moveTo(upLocation); await gesture.up(); } -Future dismissItem(WidgetTester tester, int item, { DismissDirection gestureDirection }) async { - assert(gestureDirection != DismissDirection.horizontal); - assert(gestureDirection != DismissDirection.vertical); +Future flingElement(WidgetTester tester, Finder finder, { @required AxisDirection gestureDirection, double initialOffsetFactor: 0.0 }) async { + Offset delta; + switch (gestureDirection) { + case AxisDirection.left: + delta = const Offset(-300.0, 0.0); + break; + case AxisDirection.right: + delta = const Offset(300.0, 0.0); + break; + case AxisDirection.up: + delta = const Offset(0.0, -300.0); + break; + case AxisDirection.down: + delta = const Offset(0.0, 300.0); + break; + default: + fail('unsupported gestureDirection'); + } + await tester.fling(finder, delta, 1000.0, initialOffset: delta * initialOffsetFactor); +} +Future flingElementFromZero(WidgetTester tester, Finder finder, { @required AxisDirection gestureDirection }) async { + // This is a special case where we drag in one direction, then fling back so + // that at the point of release, we're at exactly the point at which we + // started, but with velocity. This is needed to check a boundary coundition + // in the flinging behavior. + await flingElement(tester, finder, gestureDirection: gestureDirection, initialOffsetFactor: -1.0); +} + +Future dismissItem(WidgetTester tester, int item, { + @required AxisDirection gestureDirection, + DismissMethod mechanism: dismissElement, +}) async { + assert(gestureDirection != null); final Finder itemFinder = find.text(item.toString()); expect(itemFinder, findsOneWidget); - await dismissElement(tester, itemFinder, gestureDirection: gestureDirection); + await mechanism(tester, itemFinder, gestureDirection: gestureDirection); await tester.pump(); // start the slide await tester.pump(const Duration(seconds: 1)); // finish the slide and start shrinking... @@ -147,12 +173,56 @@ void main() { await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.startToEnd); + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); expect(reportedDismissDirection, DismissDirection.startToEnd); - await dismissItem(tester, 1, gestureDirection: DismissDirection.endToStart); + await dismissItem(tester, 1, gestureDirection: AxisDirection.left); + expect(find.text('1'), findsNothing); + expect(dismissedItems, equals([0, 1])); + expect(reportedDismissDirection, DismissDirection.endToStart); + }); + + testWidgets('Horizontal fling triggers dismiss scrollDirection=vertical', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.horizontal; + + await tester.pumpWidget(buildTest()); + 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); + + await dismissItem(tester, 1, gestureDirection: AxisDirection.left, mechanism: flingElement); + expect(find.text('1'), findsNothing); + expect(dismissedItems, equals([0, 1])); + expect(reportedDismissDirection, DismissDirection.endToStart); + }); + + testWidgets('Horizontal fling does not trigger at zero offset, but does otherwise', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.horizontal; + + await tester.pumpWidget(buildTest(startToEndThreshold: 0.95)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right, mechanism: flingElementFromZero); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, equals([])); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.left, mechanism: flingElementFromZero); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, equals([])); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right, mechanism: flingElement); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + expect(reportedDismissDirection, 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); @@ -165,51 +235,153 @@ void main() { await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.up); + await dismissItem(tester, 0, gestureDirection: AxisDirection.up); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); expect(reportedDismissDirection, DismissDirection.up); - await dismissItem(tester, 1, gestureDirection: DismissDirection.down); + await dismissItem(tester, 1, gestureDirection: AxisDirection.down); expect(find.text('1'), findsNothing); expect(dismissedItems, equals([0, 1])); expect(reportedDismissDirection, DismissDirection.down); }); - testWidgets('drag-left with DismissDirection.left triggers dismiss', (WidgetTester tester) async { + testWidgets('drag-left with DismissDirection.endToStart triggers dismiss (LTR)', (WidgetTester tester) async { scrollDirection = Axis.vertical; dismissDirection = DismissDirection.endToStart; await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.startToEnd); + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); expect(find.text('0'), findsOneWidget); expect(dismissedItems, isEmpty); - await dismissItem(tester, 1, gestureDirection: DismissDirection.startToEnd); + await dismissItem(tester, 1, gestureDirection: AxisDirection.right); - await dismissItem(tester, 0, gestureDirection: DismissDirection.endToStart); + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); - await dismissItem(tester, 1, gestureDirection: DismissDirection.endToStart); + await dismissItem(tester, 1, gestureDirection: AxisDirection.left); }); - testWidgets('drag-right with DismissDirection.right triggers dismiss', (WidgetTester tester) async { + testWidgets('drag-right with DismissDirection.startToEnd triggers dismiss (LTR)', (WidgetTester tester) async { scrollDirection = Axis.vertical; dismissDirection = DismissDirection.startToEnd; await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.endToStart); + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); expect(find.text('0'), findsOneWidget); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.startToEnd); + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); }); + testWidgets('drag-right with DismissDirection.endToStart triggers dismiss (RTL)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.endToStart; + + await tester.pumpWidget(buildTest(textDirection: TextDirection.rtl)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('drag-left with DismissDirection.startToEnd triggers dismiss (RTL)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.startToEnd; + + await tester.pumpWidget(buildTest(textDirection: TextDirection.rtl)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + await dismissItem(tester, 1, gestureDirection: AxisDirection.right); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + await dismissItem(tester, 1, gestureDirection: AxisDirection.left); + }); + + testWidgets('fling-left with DismissDirection.endToStart triggers dismiss (LTR)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.endToStart; + + await tester.pumpWidget(buildTest()); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + await dismissItem(tester, 1, gestureDirection: AxisDirection.right); + + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + await dismissItem(tester, 1, gestureDirection: AxisDirection.left); + }); + + testWidgets('fling-right with DismissDirection.startToEnd triggers dismiss (LTR)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.startToEnd; + + await tester.pumpWidget(buildTest()); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('fling-right with DismissDirection.endToStart triggers dismiss (RTL)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.endToStart; + + await tester.pumpWidget(buildTest(textDirection: TextDirection.rtl)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('fling-left with DismissDirection.startToEnd triggers dismiss (RTL)', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.startToEnd; + + await tester.pumpWidget(buildTest(textDirection: TextDirection.rtl)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + await dismissItem(tester, 1, mechanism: flingElement, gestureDirection: AxisDirection.right); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + await dismissItem(tester, 1, mechanism: flingElement, gestureDirection: AxisDirection.left); + }); + testWidgets('drag-up with DismissDirection.up triggers dismiss', (WidgetTester tester) async { scrollDirection = Axis.horizontal; dismissDirection = DismissDirection.up; @@ -217,11 +389,11 @@ void main() { await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.down); + await dismissItem(tester, 0, gestureDirection: AxisDirection.down); expect(find.text('0'), findsOneWidget); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.up); + await dismissItem(tester, 0, gestureDirection: AxisDirection.up); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); }); @@ -233,11 +405,43 @@ void main() { await tester.pumpWidget(buildTest()); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.up); + await dismissItem(tester, 0, gestureDirection: AxisDirection.up); expect(find.text('0'), findsOneWidget); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.down); + await dismissItem(tester, 0, gestureDirection: AxisDirection.down); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('fling-up with DismissDirection.up triggers dismiss', (WidgetTester tester) async { + scrollDirection = Axis.horizontal; + dismissDirection = DismissDirection.up; + + await tester.pumpWidget(buildTest()); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.down); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.up); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('fling-down with DismissDirection.down triggers dismiss', (WidgetTester tester) async { + scrollDirection = Axis.horizontal; + dismissDirection = DismissDirection.down; + + await tester.pumpWidget(buildTest()); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.up); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.down); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); }); @@ -249,11 +453,27 @@ void main() { await tester.pumpWidget(buildTest(startToEndThreshold: 1.0)); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.startToEnd); + await dismissItem(tester, 0, gestureDirection: AxisDirection.right); expect(find.text('0'), findsOneWidget); expect(dismissedItems, isEmpty); - await dismissItem(tester, 0, gestureDirection: DismissDirection.endToStart); + await dismissItem(tester, 0, gestureDirection: AxisDirection.left); + expect(find.text('0'), findsNothing); + expect(dismissedItems, equals([0])); + }); + + testWidgets('fling-left has no effect on dismissible with a high dismiss threshold', (WidgetTester tester) async { + scrollDirection = Axis.vertical; + dismissDirection = DismissDirection.horizontal; + + await tester.pumpWidget(buildTest(startToEndThreshold: 1.0)); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.right); + expect(find.text('0'), findsOneWidget); + expect(dismissedItems, isEmpty); + + await dismissItem(tester, 0, mechanism: flingElement, gestureDirection: AxisDirection.left); expect(find.text('0'), findsNothing); expect(dismissedItems, equals([0])); }); @@ -309,12 +529,12 @@ void main() { ); expect(find.text('1'), findsOneWidget); expect(find.text('2'), findsOneWidget); - await dismissElement(tester, find.text('2'), gestureDirection: DismissDirection.startToEnd); + await dismissElement(tester, find.text('2'), gestureDirection: AxisDirection.right); await tester.pump(); // start the slide away await tester.pump(const Duration(seconds: 1)); // finish the slide away expect(find.text('1'), findsOneWidget); expect(find.text('2'), findsNothing); - await dismissElement(tester, find.text('1'), gestureDirection: DismissDirection.startToEnd); + await dismissElement(tester, find.text('1'), gestureDirection: AxisDirection.right); await tester.pump(); // start the slide away await tester.pump(const Duration(seconds: 1)); // finish the slide away (at which point the child is no longer included in the tree) expect(find.text('1'), findsNothing); @@ -331,7 +551,7 @@ void main() { final Finder itemFinder = find.text('0'); expect(itemFinder, findsOneWidget); - await dismissElement(tester, itemFinder, gestureDirection: DismissDirection.startToEnd); + await dismissElement(tester, itemFinder, gestureDirection: AxisDirection.right); await tester.pump(); expect(find.text('background'), findsOneWidget); // The other four have been culled. diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index af172ceccf9..42ddce4f1f6 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -299,11 +299,28 @@ class WidgetController { /// /// A fling is essentially a drag that ends at a particular speed. If you /// just want to drag and end without a fling, use [drag]. + /// + /// The `initialOffset` argument, if non-zero, causes the pointer to first + /// apply that offset, then pump a delay of `initialOffsetDelay`. This can be + /// used to simulate a drag followed by a fling, including dragging in the + /// opposite direction of the fling (e.g. dragging 200 pixels to the right, + /// then fling to the left over 200 pixels, ending at the exact point that the + /// drag started). Future fling(Finder finder, Offset offset, double speed, { int pointer, Duration frameInterval: const Duration(milliseconds: 16), + Offset initialOffset: Offset.zero, + Duration initialOffsetDelay: const Duration(seconds: 1), }) { - return flingFrom(getCenter(finder), offset, speed, pointer: pointer, frameInterval: frameInterval); + return flingFrom( + getCenter(finder), + offset, + speed, + pointer: pointer, + frameInterval: frameInterval, + initialOffset: initialOffset, + initialOffsetDelay: initialOffsetDelay, + ); } /// Attempts a fling gesture starting from the given location, moving the @@ -324,7 +341,19 @@ class WidgetController { /// /// A fling is essentially a drag that ends at a particular speed. If you /// just want to drag and end without a fling, use [dragFrom]. - Future flingFrom(Offset startLocation, Offset offset, double speed, { int pointer, Duration frameInterval: const Duration(milliseconds: 16) }) { + /// + /// The `initialOffset` argument, if non-zero, causes the pointer to first + /// apply that offset, then pump a delay of `initialOffsetDelay`. This can be + /// used to simulate a drag followed by a fling, including dragging in the + /// opposite direction of the fling (e.g. dragging 200 pixels to the right, + /// then fling to the left over 200 pixels, ending at the exact point that the + /// drag started). + Future flingFrom(Offset startLocation, Offset offset, double speed, { + int pointer, + Duration frameInterval: const Duration(milliseconds: 16), + Offset initialOffset: Offset.zero, + Duration initialOffsetDelay: const Duration(seconds: 1), + }) { assert(offset.distance > 0.0); assert(speed > 0.0); // speed is pixels/second return TestAsyncUtils.guard(() async { @@ -335,8 +364,13 @@ class WidgetController { double timeStamp = 0.0; double lastTimeStamp = timeStamp; await sendEventToBinding(testPointer.down(startLocation, timeStamp: new Duration(milliseconds: timeStamp.round())), result); + if (initialOffset.distance > 0.0) { + await sendEventToBinding(testPointer.move(startLocation + initialOffset, timeStamp: new Duration(milliseconds: timeStamp.round())), result); + timeStamp += initialOffsetDelay.inMilliseconds; + await pump(initialOffsetDelay); + } for (int i = 0; i <= kMoveCount; i += 1) { - final Offset location = startLocation + Offset.lerp(Offset.zero, offset, i / kMoveCount); + final Offset location = startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount); await sendEventToBinding(testPointer.move(location, timeStamp: new Duration(milliseconds: timeStamp.round())), result); timeStamp += timeStampDelta; if (timeStamp - lastTimeStamp > frameInterval.inMilliseconds) {