diff --git a/packages/flutter/lib/src/widgets/scroll_activity.dart b/packages/flutter/lib/src/widgets/scroll_activity.dart index b97a5ad86e..0e9b414de8 100644 --- a/packages/flutter/lib/src/widgets/scroll_activity.dart +++ b/packages/flutter/lib/src/widgets/scroll_activity.dart @@ -227,12 +227,18 @@ class ScrollDragController implements Drag { @required DragStartDetails details, this.onDragCanceled, this.carriedVelocity, + this.motionStartDistanceThreshold, }) : assert(delegate != null), assert(details != null), + assert( + motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0, + 'motionStartDistanceThreshold must be a positive number or null' + ), _delegate = delegate, _lastDetails = details, _retainMomentum = carriedVelocity != null && carriedVelocity != 0.0, - _lastNonStationaryTimestamp = details.sourceTimeStamp; + _lastNonStationaryTimestamp = details.sourceTimeStamp, + _offsetSinceLastStop = motionStartDistanceThreshold == null ? null : 0.0; /// The object that will actuate the scroll view as the user drags. ScrollActivityDelegate get delegate => _delegate; @@ -245,15 +251,27 @@ class ScrollDragController implements Drag { /// began. final double carriedVelocity; + /// Amount of pixels in either direction the drag has to move by to start + /// scroll movement again after each time scrolling came to a stop. + final double motionStartDistanceThreshold; + Duration _lastNonStationaryTimestamp; bool _retainMomentum; + /// Null if already in motion or has no [motionStartDistanceThreshold]. + double _offsetSinceLastStop; /// Maximum amount of time interval the drag can have consecutive stationary /// pointer update events before losing the momentum carried from a previous /// scroll activity. - static const Duration momentumRetainStationaryThreshold = + static const Duration momentumRetainStationaryDurationThreshold = const Duration(milliseconds: 20); + /// Maximum amount of time interval the drag can have consecutive stationary + /// pointer update events before needing to break the + /// [motionStartDistanceThreshold] to start motion again. + static const Duration motionStoppedDurationThreshold = + const Duration(milliseconds: 50); + bool get _reversed => axisDirectionIsReversed(delegate.axisDirection); /// Updates the controller's link to the [ScrollActivityDelegate]. @@ -265,22 +283,67 @@ class ScrollDragController implements Drag { _delegate = value; } + /// Determines whether to lose the existing incoming velocity when starting + /// the drag. + void _maybeLoseMomentum(double offset, Duration timestamp) { + if (_retainMomentum && + offset == 0.0 && + (timestamp == null || // If drag event has no timestamp, we lose momentum. + timestamp - _lastNonStationaryTimestamp > momentumRetainStationaryDurationThreshold)) { + // If pointer is stationary for too long, we lose momentum. + _retainMomentum = false; + } + } + + /// If a motion start threshold exists, determine whether the threshold is + /// reached to start applying position offset. + /// + /// Returns false either way if there's no offset. + bool _breakMotionStartThreshold(double offset, Duration timestamp) { + if (timestamp == null) { + // If we can't track time, we can't apply thresholds. + // May be null for proxied drags like via accessibility. + return true; + } + + if (offset == 0.0) { + if (motionStartDistanceThreshold != null && + _offsetSinceLastStop == null && + timestamp - _lastNonStationaryTimestamp > motionStoppedDurationThreshold) { + // Enforce a new threshold. + _offsetSinceLastStop = 0.0; + } + // Not moving can't break threshold. + return false; + } else { + if (_offsetSinceLastStop == null) { + // Already in motion. Allow transparent offset transmission. + return true; + } else { + _offsetSinceLastStop += offset; + if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) { + // Threshold broken. + _offsetSinceLastStop = null; + return true; + } else { + return false; + } + } + } + } + @override void update(DragUpdateDetails details) { assert(details.primaryDelta != null); _lastDetails = details; double offset = details.primaryDelta; - if (offset == 0.0) { - if (_retainMomentum && - (details.sourceTimeStamp == null || // If drag event has no timestamp, we lose momentum. - details.sourceTimeStamp - _lastNonStationaryTimestamp > momentumRetainStationaryThreshold )) { - // If pointer is stationary for too long, we lose momentum. - _retainMomentum = false; - } - return; - } else { + if (offset != 0) { _lastNonStationaryTimestamp = details.sourceTimeStamp; } + _maybeLoseMomentum(offset, details.sourceTimeStamp); + if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) { + return; + } if (_reversed) // e.g. an AxisDirection.up scrollable offset = -offset; delegate.applyUserOffset(offset); diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index 0b111a827a..299a15a909 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -207,6 +207,12 @@ class ScrollPhysics { return parent.carriedMomentum(existingVelocity); } + /// The minimum amount of pixel distance drags must move by to start motion + /// the first time or after each time the drag motion stopped. + /// + /// If null, no minimum threshold is enforced. + double get dragStartDistanceMotionThreshold => parent?.dragStartDistanceMotionThreshold; + @override String toString() { if (parent == null) @@ -326,6 +332,9 @@ class BouncingScrollPhysics extends ScrollPhysics { return existingVelocity.sign * math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0); } + + @override + double get dragStartDistanceMotionThreshold => 3.5; // Eyeballed from observation. } /// Scroll physics for environments that prevent the scroll offset from reaching diff --git a/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart b/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart index b355079dc5..a8a35bfc6f 100644 --- a/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart +++ b/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart @@ -246,6 +246,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc details: details, onDragCanceled: onDragCanceled, carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity), + motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold, ); beginActivity(new DragScrollActivity(this, drag)); assert(_currentDrag == null); diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index bbd82c09b6..246143507b 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -133,4 +133,79 @@ void main() { // After a hold longer than 2 frames, previous velocity is lost. expect(getScrollVelocity(tester), 0.0); }); + + testWidgets('Drags creeping unaffected on Android', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.android); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + await gesture.moveBy(const Offset(0.0, -0.5)); + expect(getScrollOffset(tester), 0.5); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10)); + expect(getScrollOffset(tester), 1.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); + expect(getScrollOffset(tester), 1.5); + }); + + testWidgets('Drags creeping must break threshold on iOS', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.iOS); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + await gesture.moveBy(const Offset(0.0, -0.5)); + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10)); + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 30)); + // Now -2.5 in total. + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 40)); + // Now -3.5, just reached threshold. + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 50)); + // -0.5 over threshold transferred. + expect(getScrollOffset(tester), 0.5); + }); + + testWidgets('Big drag over threshold magnitude preserved on iOS', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.iOS); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + await gesture.moveBy(const Offset(0.0, -20.0)); + // No offset lost from threshold. + expect(getScrollOffset(tester), 20.0); + }); + + testWidgets('Small continuing motion preserved on iOS', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.iOS); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold. + expect(getScrollOffset(tester), 20.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); + expect(getScrollOffset(tester), 20.5); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40)); + expect(getScrollOffset(tester), 21.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60)); + expect(getScrollOffset(tester), 21.5); + }); + + testWidgets('Motion stop resets threshold on iOS', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.iOS); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold. + expect(getScrollOffset(tester), 20.0); + await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); + expect(getScrollOffset(tester), 20.5); + await gesture.moveBy(Offset.zero); + // Stationary too long, threshold reset. + await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 120)); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 140)); + expect(getScrollOffset(tester), 20.5); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150)); + expect(getScrollOffset(tester), 20.5); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160)); + expect(getScrollOffset(tester), 20.5); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170)); + // New threshold broken. + expect(getScrollOffset(tester), 21.5); + await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180)); + expect(getScrollOffset(tester), 22.5); + }); } diff --git a/packages/flutter_test/lib/src/test_pointer.dart b/packages/flutter_test/lib/src/test_pointer.dart index 6b3ecd77bb..00553a16f4 100644 --- a/packages/flutter_test/lib/src/test_pointer.dart +++ b/packages/flutter_test/lib/src/test_pointer.dart @@ -163,16 +163,16 @@ class TestGesture { final TestPointer _pointer; /// Send a move event moving the pointer by the given offset. - Future moveBy(Offset offset) { + Future moveBy(Offset offset, { Duration timeStamp: Duration.ZERO }) { assert(_pointer._isDown); - return moveTo(_pointer.location + offset); + return moveTo(_pointer.location + offset, timeStamp: timeStamp); } /// Send a move event moving the pointer to the given location. - Future moveTo(Offset location) { + Future moveTo(Offset location, { Duration timeStamp: Duration.ZERO }) { return TestAsyncUtils.guard(() { assert(_pointer._isDown); - return _dispatcher(_pointer.move(location), _result); + return _dispatcher(_pointer.move(location, timeStamp: timeStamp), _result); }); }