diff --git a/packages/flutter/lib/src/widgets/scroll_activity.dart b/packages/flutter/lib/src/widgets/scroll_activity.dart index 2f533f3e12..1c5d774e88 100644 --- a/packages/flutter/lib/src/widgets/scroll_activity.dart +++ b/packages/flutter/lib/src/widgets/scroll_activity.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -272,6 +273,10 @@ class ScrollDragController implements Drag { static const Duration motionStoppedDurationThreshold = const Duration(milliseconds: 50); + /// The drag distance past which, a [motionStartDistanceThreshold] breaking + /// drag is considered a deliberate fling. + static const double _kBigThresholdBreakDistance = 24.0; + bool get _reversed => axisDirectionIsReversed(delegate.axisDirection); /// Updates the controller's link to the [ScrollActivityDelegate]. @@ -295,15 +300,17 @@ class ScrollDragController implements Drag { } } - /// If a motion start threshold exists, determine whether the threshold is - /// reached to start applying position offset. + /// If a motion start threshold exists, determine whether the threshold needs + /// to be broken to scroll. Also possibly apply an offset adjustment when + /// threshold is first broken. /// - /// Returns false either way if there's no offset. - bool _breakMotionStartThreshold(double offset, Duration timestamp) { + /// Returns `0.0` when stationary or within threshold. Returns `offset` + /// transparently when already in motion. + double _adjustForScrollStartThreshold(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; + return offset; } if (offset == 0.0) { @@ -314,19 +321,32 @@ class ScrollDragController implements Drag { _offsetSinceLastStop = 0.0; } // Not moving can't break threshold. - return false; + return 0.0; } else { if (_offsetSinceLastStop == null) { - // Already in motion. Allow transparent offset transmission. - return true; + // Already in motion or no threshold behavior configured such as for + // Android. Allow transparent offset transmission. + return offset; } else { _offsetSinceLastStop += offset; if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) { // Threshold broken. _offsetSinceLastStop = null; - return true; + if (offset.abs() > _kBigThresholdBreakDistance) { + // This is heuristically a very deliberate fling. Leave the motion + // unaffected. + return offset; + } else { + // This is a normal speed threshold break. + return math.min( + // Ease into the motion when the threshold is initially broken + // to avoid a visible jump. + motionStartDistanceThreshold / 3.0, + offset.abs() + ) * offset.sign; + } } else { - return false; + return 0.0; } } } @@ -337,11 +357,15 @@ class ScrollDragController implements Drag { assert(details.primaryDelta != null); _lastDetails = details; double offset = details.primaryDelta; - if (offset != 0) { + if (offset != 0.0) { _lastNonStationaryTimestamp = details.sourceTimeStamp; } + // By default, iOS platforms carries momentum and has a start threshold + // (configured in [BouncingScrollPhysics]). The 2 operations below are + // no-ops on Android. _maybeLoseMomentum(offset, details.sourceTimeStamp); - if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) { + offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp); + if (offset == 0.0) { return; } if (_reversed) // e.g. an AxisDirection.up scrollable diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index 957616f8fd..149b1bbec2 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -345,8 +345,10 @@ class BouncingScrollPhysics extends ScrollPhysics { math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0); } + // Eyeballed from observation to counter the effect of an unintended scroll + // from the natural motion of lifting the finger after a scroll. @override - double get dragStartDistanceMotionThreshold => 3.5; // Eyeballed from observation. + double get dragStartDistanceMotionThreshold => 3.5; } /// Scroll physics for environments that prevent the scroll offset from reaching diff --git a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart index e8cda50608..7280d4d64a 100644 --- a/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart +++ b/packages/flutter/test/widgets/list_wheel_scroll_view_test.dart @@ -727,7 +727,8 @@ void main() { expect(controller.selectedItem, 46); // A tester.fling creates and pumps 50 pointer events. expect(scrolledPositions.length, 50); - expect(scrolledPositions.last, moreOrLessEquals(40 * 100.0 + 567.0, epsilon: 0.2)); + // iOS flings ease-in initially. + expect(scrolledPositions.last, moreOrLessEquals(40 * 100.0 + 556.826666666673, epsilon: 0.2)); // Let the spring back simulation finish. await tester.pumpAndSettle(); @@ -737,7 +738,7 @@ void main() { // Lands on 49. expect(controller.selectedItem, 49); // More importantly, lands tightly on 49. - expect(scrolledPositions.last, moreOrLessEquals(49 * 100.0, epsilon: 0.2)); + expect(scrolledPositions.last, moreOrLessEquals(49 * 100.0, epsilon: 0.3)); debugDefaultTargetPlatformOverride = null; }); diff --git a/packages/flutter/test/widgets/scrollable_fling_test.dart b/packages/flutter/test/widgets/scrollable_fling_test.dart index fed2bcf663..3a2075285b 100644 --- a/packages/flutter/test/widgets/scrollable_fling_test.dart +++ b/packages/flutter/test/widgets/scrollable_fling_test.dart @@ -46,9 +46,10 @@ void main() { await pumpTest(tester, TargetPlatform.iOS); await tester.fling(find.byType(ListView), const Offset(0.0, -dragOffset), 1000.0); - expect(getCurrentOffset(), dragOffset); + // Scroll starts ease into the scroll on iOS. + expect(getCurrentOffset(), moreOrLessEquals(210.71026666666666)); await tester.pump(); // trigger fling - expect(getCurrentOffset(), dragOffset); + expect(getCurrentOffset(), moreOrLessEquals(210.71026666666666)); await tester.pump(const Duration(seconds: 5)); final double result2 = getCurrentOffset(); diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index c5f43956d9..76739fdbd4 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -55,9 +55,10 @@ void main() { await pumpTest(tester, TargetPlatform.iOS); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0); - expect(getScrollOffset(tester), dragOffset); + // Scroll starts ease into the scroll on iOS. + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(); // trigger fling - expect(getScrollOffset(tester), dragOffset); + expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669)); await tester.pump(const Duration(seconds: 5)); final double result2 = getScrollOffset(tester); @@ -72,11 +73,13 @@ void main() { await tester.pump(const Duration(milliseconds: 10)); expect(getScrollOffset(tester), greaterThan(-200.0)); expect(getScrollOffset(tester), lessThan(0.0)); - final double position = getScrollOffset(tester); + final double heldPosition = getScrollOffset(tester); + // Hold and let go while in overscroll. final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); expect(await tester.pumpAndSettle(), 1); - expect(getScrollOffset(tester), position); + expect(getScrollOffset(tester), heldPosition); await gesture.up(); + // Once the hold is let go, it should still snap back to origin. expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); expect(getScrollOffset(tester), 0.0); }); @@ -168,44 +171,55 @@ void main() { 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)); + await gesture.moveBy(const Offset(0.0, -30.0)); // No offset lost from threshold. - expect(getScrollOffset(tester), 20.0); + expect(getScrollOffset(tester), 30.0); + }); + + testWidgets('Slow threshold breaks are attenuated on iOS', (WidgetTester tester) async { + await pumpTest(tester, TargetPlatform.iOS); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); + // This is a typical 'hesitant' iOS scroll start. + await gesture.moveBy(const Offset(0.0, -10.0)); + expect(getScrollOffset(tester), moreOrLessEquals(1.1666666666666667)); + await gesture.moveBy(const Offset(0.0, -10.0), timeStamp: const Duration(milliseconds: 20)); + // Subsequent motions unaffected. + expect(getScrollOffset(tester), moreOrLessEquals(11.16666666666666673)); }); 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, -30.0)); // Break threshold. + expect(getScrollOffset(tester), 30.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); - expect(getScrollOffset(tester), 20.5); + expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40)); - expect(getScrollOffset(tester), 21.0); + expect(getScrollOffset(tester), 31.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60)); - expect(getScrollOffset(tester), 21.5); + expect(getScrollOffset(tester), 31.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, -30.0)); // Break threshold. + expect(getScrollOffset(tester), 30.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); - expect(getScrollOffset(tester), 20.5); + expect(getScrollOffset(tester), 30.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); + expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150)); - expect(getScrollOffset(tester), 20.5); + expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160)); - expect(getScrollOffset(tester), 20.5); + expect(getScrollOffset(tester), 30.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170)); // New threshold broken. - expect(getScrollOffset(tester), 21.5); + expect(getScrollOffset(tester), 31.5); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180)); - expect(getScrollOffset(tester), 22.5); + expect(getScrollOffset(tester), 32.5); }); }