Make Scrollable's free scroll initial velocity matches that of iOS (#60501)
diff --git a/dev/benchmarks/microbenchmarks/README.md b/dev/benchmarks/microbenchmarks/README.md index eda8bae..82f0ecb 100644 --- a/dev/benchmarks/microbenchmarks/README.md +++ b/dev/benchmarks/microbenchmarks/README.md
@@ -5,7 +5,7 @@ these: ``` -flutter run --release lib/gestures/velocity_tracker_data.dart +flutter run --release lib/gestures/velocity_tracker_bench.dart flutter run --release lib/gestures/gesture_detector_bench.dart flutter run --release lib/stocks/animation_bench.dart flutter run --release lib/stocks/build_bench.dart
diff --git a/dev/benchmarks/microbenchmarks/lib/gestures/velocity_tracker_bench.dart b/dev/benchmarks/microbenchmarks/lib/gestures/velocity_tracker_bench.dart index cfd1546..b4b7e06 100644 --- a/dev/benchmarks/microbenchmarks/lib/gestures/velocity_tracker_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/gestures/velocity_tracker_bench.dart
@@ -11,26 +11,31 @@ void main() { assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'."); - final VelocityTracker tracker = VelocityTracker(); - final Stopwatch watch = Stopwatch(); - print('Velocity tracker benchmark...'); - watch.start(); - for (int i = 0; i < _kNumIters; i += 1) { - for (final PointerEvent event in velocityEventData) { - if (event is PointerDownEvent || event is PointerMoveEvent) - tracker.addPosition(event.timeStamp, event.position); - if (event is PointerUpEvent) - tracker.getVelocity(); - } - } - watch.stop(); - final BenchmarkResultPrinter printer = BenchmarkResultPrinter(); - printer.addResult( - description: 'Velocity tracker', - value: watch.elapsedMicroseconds / _kNumIters, - unit: 'µs per iteration', - name: 'velocity_tracker_iteration', - ); + final List<VelocityTracker> trackers = <VelocityTracker>[VelocityTracker(), IOSScrollViewFlingVelocityTracker()]; + final Stopwatch watch = Stopwatch(); + + for (final VelocityTracker tracker in trackers) { + final String trackerType = tracker.runtimeType.toString(); + print('$trackerType benchmark...'); + watch.reset(); + watch.start(); + for (int i = 0; i < _kNumIters; i += 1) { + for (final PointerEvent event in velocityEventData) { + if (event is PointerDownEvent || event is PointerMoveEvent) + tracker.addPosition(event.timeStamp, event.position); + if (event is PointerUpEvent) + tracker.getVelocity(); + } + } + watch.stop(); + printer.addResult( + description: 'Velocity tracker: $trackerType', + value: watch.elapsedMicroseconds / _kNumIters, + unit: 'µs per iteration', + name: 'velocity_tracker_iteration_$trackerType', + ); + } + printer.printToStdout(); }
diff --git a/packages/flutter/lib/src/gestures/monodrag.dart b/packages/flutter/lib/src/gestures/monodrag.dart index 36f250a..8db91f1 100644 --- a/packages/flutter/lib/src/gestures/monodrag.dart +++ b/packages/flutter/lib/src/gestures/monodrag.dart
@@ -34,6 +34,8 @@ /// See [DragGestureRecognizer.onCancel]. typedef GestureDragCancelCallback = void Function(); +typedef GestureVelocityTrackerBuilder = VelocityTracker Function(PointerEvent event); + /// Recognizes movement. /// /// In contrast to [MultiDragGestureRecognizer], [DragGestureRecognizer] @@ -64,9 +66,11 @@ Object? debugOwner, PointerDeviceKind? kind, this.dragStartBehavior = DragStartBehavior.start, + this.velocityTrackerBuilder = _defaultBuilder, }) : assert(dragStartBehavior != null), super(debugOwner: debugOwner, kind: kind); + static VelocityTracker _defaultBuilder(PointerEvent ev) => VelocityTracker(); /// Configure the behavior of offsets sent to [onStart]. /// /// If set to [DragStartBehavior.start], the [onStart] callback will be called @@ -170,6 +174,30 @@ /// If null then [kMaxFlingVelocity] is used. double? maxFlingVelocity; + /// Determines the type of velocity estimation method to use for a potential + /// drag gesture, when a new pointer is added. + /// + /// To estimate the velocity of a gesture, [DragGestureRecognizer] calls + /// [velocityTrackerBuilder] when it starts to track a new pointer in + /// [addAllowedPointer], and add subsequent updates on the pointer to the + /// resulting velocity tracker, until the gesture recognizer stops tracking + /// the pointer. This allows you to specify a different velocity estimation + /// strategy for each allowed pointer added, by changing the type of velocity + /// tracker this [GestureVelocityTrackerBuilder] returns. + /// + /// If left unspecified the default [velocityTrackerBuilder] creates a new + /// [VelocityTracker] for every pointer added. + /// + /// See also: + /// + /// * [VelocityTracker], a velocity tracker that uses least squares estimation + /// on the 20 most recent pointer data samples. It's a well-rounded velocity + /// tracker and is used by default. + /// * [IOSScrollViewFlingVelocityTracker], a specialized velocity tracker for + /// determining the initial fling velocity for a [Scrollable] on iOS, to + /// match the native behavior on that platform. + GestureVelocityTrackerBuilder velocityTrackerBuilder; + _DragState _state = _DragState.ready; late OffsetPair _initialPosition; late OffsetPair _pendingDragOffset; @@ -225,7 +253,7 @@ @override void addAllowedPointer(PointerEvent event) { startTrackingPointer(event.pointer, event.transform); - _velocityTrackers[event.pointer] = VelocityTracker(); + _velocityTrackers[event.pointer] = velocityTrackerBuilder(event); if (_state == _DragState.ready) { _state = _DragState.possible; _initialPosition = OffsetPair(global: event.position, local: event.localPosition);
diff --git a/packages/flutter/lib/src/gestures/velocity_tracker.dart b/packages/flutter/lib/src/gestures/velocity_tracker.dart index 4de7134..b820e23 100644 --- a/packages/flutter/lib/src/gestures/velocity_tracker.dart +++ b/packages/flutter/lib/src/gestures/velocity_tracker.dart
@@ -192,8 +192,8 @@ if (sample == null) break; - final double age = (newestSample.time - sample.time).inMilliseconds.toDouble(); - final double delta = (sample.time - previousSample.time).inMilliseconds.abs().toDouble(); + final double age = (newestSample.time - sample.time).inMicroseconds.toDouble() / 1000; + final double delta = (sample.time - previousSample.time).inMicroseconds.abs().toDouble() / 1000; previousSample = sample; if (age > _horizonMilliseconds || delta > _assumePointerMoveStoppedMilliseconds) break; @@ -250,3 +250,110 @@ return Velocity(pixelsPerSecond: estimate.pixelsPerSecond); } } + +/// A [VelocityTracker] subclass that provides a close approximation of iOS +/// scroll view's velocity estimation strategy. +/// +/// The estimated velocity reported by this class is a close approximation of +/// the velocity an iOS scroll view would report with the same +/// [PointerMoveEvent]s, when the touch that initiates a fling is released. +/// +/// This class differs from the [VelocityTracker] class in that it uses weighted +/// average of the latest few velocity samples of the tracked pointer, instead +/// of doing a linear regression on a relatively large amount of data points, to +/// estimate the velocity of the tracked pointer. Adding data points and +/// estimating the velocity are both cheap. +/// +/// To obtain a velocity, call [getVelocity] or [getVelocityEstimate]. The +/// esimated velocity is typically used as the initial flinging velocity of a +/// `Scrollable`, when its drag gesture ends. +/// +/// See also: +/// +/// * [scrollViewWillEndDragging(_:withVelocity:targetContentOffset:)](https://developer.apple.com/documentation/uikit/uiscrollviewdelegate/1619385-scrollviewwillenddragging), +/// the iOS method that reports the fling velocity when the touch is released. +class IOSScrollViewFlingVelocityTracker extends VelocityTracker { + /// The velocity estimation uses at most 4 `_PointAtTime` samples. The extra + /// samples are there to make the `VelocityEstimate.offset` sufficiently large + /// to be recognized as a fling. See + /// `VerticalDragGestureRecognizer.isFlingGesture`. + static const int _sampleSize = 20; + + final List<_PointAtTime?> _touchSamples = List<_PointAtTime?>.filled(_sampleSize, null, growable: false); + + @override + void addPosition(Duration time, Offset position) { + assert(() { + final _PointAtTime? previousPoint = _touchSamples[_index]; + if (previousPoint == null || previousPoint.time <= time) + return true; + throw FlutterError( + 'The position being added ($position) has a smaller timestamp ($time)' + 'than its predecessor: $previousPoint.' + ); + }()); + _index = (_index + 1) % _sampleSize; + _touchSamples[_index] = _PointAtTime(position, time); + } + + // Computes the velocity using 2 adjacent points in history. When index = 0, + // it uses the latest point recorded and the point recorded immediately before + // it. The smaller index is, the ealier in history the points used are. + Offset _previousVelocityAt(int index) { + final int endIndex = (_index + index) % _sampleSize; + final int startIndex = (_index + index - 1) % _sampleSize; + final _PointAtTime? end = _touchSamples[endIndex]; + final _PointAtTime? start = _touchSamples[startIndex]; + + if (end == null || start == null) { + return Offset.zero; + } + + final int dt = (end.time - start.time).inMicroseconds; + assert(dt >= 0); + + return dt > 0 + // Convert dt to milliseconds to preserve floating point precision. + ? (end.point - start.point) * 1000 / (dt.toDouble() / 1000) + : Offset.zero; + } + + @override + VelocityEstimate getVelocityEstimate() { + // The velocity estimated using this expression is an aproximation of the + // scroll velocity of an iOS scroll view at the moment the user touch was + // released, not the final velocity of the iOS pan gesture recognizer + // installed on the scroll view would report. Typically in an iOS scroll + // view the velocity values are different between the two, because the + // scroll view usually slows down when the touch is released. + final Offset estimatedVelocity = _previousVelocityAt(-2) * 0.6 + + _previousVelocityAt(-1) * 0.35 + + _previousVelocityAt(0) * 0.05; + + final _PointAtTime? newestSample = _touchSamples[_index]; + _PointAtTime? oldestNonNullSample; + + for (int i = 1; i <= _sampleSize; i += 1) { + oldestNonNullSample = _touchSamples[(_index + i) % _sampleSize]; + if (oldestNonNullSample != null) + break; + } + + if (oldestNonNullSample == null || newestSample == null) { + assert(false, 'There must be at least 1 point in _touchSamples: $_touchSamples'); + return const VelocityEstimate( + pixelsPerSecond: Offset.zero, + confidence: 0.0, + duration: Duration.zero, + offset: Offset.zero, + ); + } else { + return VelocityEstimate( + pixelsPerSecond: estimatedVelocity, + confidence: 1.0, + duration: newestSample.time - oldestNonNullSample.time, + offset: newestSample.point - oldestNonNullSample.point, + ); + } + } +}
diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 9ed3690..7678447 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart
@@ -5,6 +5,7 @@ // @dart = 2.8 import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'framework.dart'; @@ -52,6 +53,37 @@ return null; } + /// Specifies the type of velocity tracker to use in the descendant + /// [Scrollable]s' drag gesture recognizers, for estimating the velocity of a + /// drag gesture. + /// + /// This can be used to, for example, apply different fling velocity + /// estimation methods on different platforms, in order to match the + /// platform's native behavior. + /// + /// Typically, the provided [GestureVelocityTrackerBuilder] should return a + /// fresh velocity tracker. If null is returned, [Scrollable] creates a new + /// [VelocityTracker] to track the newly added pointer that may develop into + /// a drag gesture. + /// + /// The default implementation provides a new + /// [IOSScrollViewFlingVelocityTracker] on iOS and macOS for each new pointer, + /// and a new [VelocityTracker] on other platforms for each new pointer. + GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) { + switch (getPlatform(context)) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return (PointerEvent ev) => IOSScrollViewFlingVelocityTracker(); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return (PointerEvent ev) => VelocityTracker(); + } + assert(false); + return (PointerEvent ev) => VelocityTracker(); + } + static const ScrollPhysics _bouncingPhysics = BouncingScrollPhysics(parent: RangeMaintainingScrollPhysics()); static const ScrollPhysics _clampingPhysics = ClampingScrollPhysics(parent: RangeMaintainingScrollPhysics());
diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 72b735e..50ac9fe 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart
@@ -507,6 +507,7 @@ ..minFlingDistance = _physics?.minFlingDistance ..minFlingVelocity = _physics?.minFlingVelocity ..maxFlingVelocity = _physics?.maxFlingVelocity + ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) ..dragStartBehavior = widget.dragStartBehavior; }, ), @@ -526,6 +527,7 @@ ..minFlingDistance = _physics?.minFlingDistance ..minFlingVelocity = _physics?.minFlingVelocity ..maxFlingVelocity = _physics?.maxFlingVelocity + ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) ..dragStartBehavior = widget.dragStartBehavior; }, ),
diff --git a/packages/flutter/test/gestures/velocity_tracker_test.dart b/packages/flutter/test/gestures/velocity_tracker_test.dart index 03fda2b..16053f3 100644 --- a/packages/flutter/test/gestures/velocity_tracker_test.dart +++ b/packages/flutter/test/gestures/velocity_tracker_test.dart
@@ -78,4 +78,71 @@ final VelocityTracker tracker = VelocityTracker(); expect(tracker.getVelocity(), Velocity.zero); }); + + test('FreeScrollStartVelocityTracker.getVelocity throws when no points', () { + final IOSScrollViewFlingVelocityTracker tracker = IOSScrollViewFlingVelocityTracker(); + AssertionError exception; + try { + tracker.getVelocity(); + } on AssertionError catch (e) { + exception = e; + } + + expect(exception?.toString(), contains('at least 1 point')); + }); + + test('FreeScrollStartVelocityTracker.getVelocity throws when the new point precedes the previous point', () { + final IOSScrollViewFlingVelocityTracker tracker = IOSScrollViewFlingVelocityTracker(); + AssertionError exception; + + tracker.addPosition(const Duration(hours: 1), Offset.zero); + try { + tracker.getVelocity(); + tracker.addPosition(const Duration(seconds: 1), Offset.zero); + } on AssertionError catch (e) { + exception = e; + } + + expect(exception?.toString(), contains('has a smaller timestamp')); + }); + + test('Estimate does not throw when there are more than 1 point', () { + final IOSScrollViewFlingVelocityTracker tracker = IOSScrollViewFlingVelocityTracker(); + Offset position = Offset.zero; + Duration time = Duration.zero; + const Offset positionDelta = Offset(0, -1); + const Duration durationDelta = Duration(seconds: 1); + AssertionError exception; + + for (int i = 0; i < 5; i+=1) { + position += positionDelta; + time += durationDelta; + tracker.addPosition(time, position); + + try { + tracker.getVelocity(); + } on AssertionError catch (e) { + exception = e; + } + expect(exception, isNull); + } + }); + + test('Makes consistent velocity estimates with consistent velocity', () { + final IOSScrollViewFlingVelocityTracker tracker = IOSScrollViewFlingVelocityTracker(); + Offset position = Offset.zero; + Duration time = Duration.zero; + const Offset positionDelta = Offset(0, -1); + const Duration durationDelta = Duration(seconds: 1); + + for (int i = 0; i < 10; i+=1) { + position += positionDelta; + time += durationDelta; + tracker.addPosition(time, position); + + if (i >= 3) { + expect(tracker.getVelocity().pixelsPerSecond, positionDelta); + } + } + }); }
diff --git a/packages/flutter/test/widgets/scroll_behavior_test.dart b/packages/flutter/test/widgets/scroll_behavior_test.dart index 933448a..149d914 100644 --- a/packages/flutter/test/widgets/scroll_behavior_test.dart +++ b/packages/flutter/test/widgets/scroll_behavior_test.dart
@@ -4,9 +4,11 @@ // @dart = 2.8 +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +GestureVelocityTrackerBuilder lastCreatedBuilder; class TestScrollBehavior extends ScrollBehavior { const TestScrollBehavior(this.flag); @@ -21,6 +23,14 @@ @override bool shouldNotify(TestScrollBehavior old) => flag != old.flag; + + @override + GestureVelocityTrackerBuilder velocityTrackerBuilder(BuildContext context) { + lastCreatedBuilder = flag + ? (PointerEvent ev) => VelocityTracker() + : (PointerEvent ev) => IOSScrollViewFlingVelocityTracker(); + return lastCreatedBuilder; + } } void main() { @@ -50,6 +60,7 @@ expect(behavior, isNotNull); expect(behavior.flag, isTrue); expect(position.physics, isA<ClampingScrollPhysics>()); + expect(lastCreatedBuilder(const PointerDownEvent()), isA<VelocityTracker>()); ScrollMetrics metrics = position.copyWith(); expect(metrics.extentAfter, equals(400.0)); expect(metrics.viewportDimension, equals(600.0)); @@ -65,6 +76,7 @@ expect(behavior, isNotNull); expect(behavior.flag, isFalse); expect(position.physics, isA<BouncingScrollPhysics>()); + expect(lastCreatedBuilder(const PointerDownEvent()), isA<IOSScrollViewFlingVelocityTracker>()); // Regression test for https://github.com/flutter/flutter/issues/5856 metrics = position.copyWith(); expect(metrics.extentAfter, equals(400.0));
diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 6d7d036..f98ff69 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart
@@ -182,8 +182,7 @@ await tester.pump(); // The only applied velocity to the scrollable is the second fling that was in the // opposite direction. - expect(getScrollVelocity(tester), greaterThan(-1000.0)); - expect(getScrollVelocity(tester), lessThan(0.0)); + expect(getScrollVelocity(tester), -1000.0); }, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('No iOS/macOS momentum kept on hold gestures', (WidgetTester tester) async { @@ -269,7 +268,7 @@ expect(getScrollOffset(tester), 30.0); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); expect(getScrollOffset(tester), 30.5); - await gesture.moveBy(Offset.zero); + await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 21)); // 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));
diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 53ae1fb..bc8e25b 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart
@@ -380,25 +380,25 @@ final TestPointer testPointer = TestPointer(pointer ?? _getNextPointer(), PointerDeviceKind.touch, null, buttons); final HitTestResult result = hitTestOnBinding(startLocation); const int kMoveCount = 50; // Needs to be >= kHistorySize, see _LeastSquaresVelocityTrackerStrategy - final double timeStampDelta = 1000.0 * offset.distance / (kMoveCount * speed); + final double timeStampDelta = 1000000.0 * offset.distance / (kMoveCount * speed); double timeStamp = 0.0; double lastTimeStamp = timeStamp; - await sendEventToBinding(testPointer.down(startLocation, timeStamp: Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(testPointer.down(startLocation, timeStamp: Duration(microseconds: timeStamp.round())), result); if (initialOffset.distance > 0.0) { - await sendEventToBinding(testPointer.move(startLocation + initialOffset, timeStamp: Duration(milliseconds: timeStamp.round())), result); - timeStamp += initialOffsetDelay.inMilliseconds; + await sendEventToBinding(testPointer.move(startLocation + initialOffset, timeStamp: Duration(microseconds: timeStamp.round())), result); + timeStamp += initialOffsetDelay.inMicroseconds; await pump(initialOffsetDelay); } for (int i = 0; i <= kMoveCount; i += 1) { final Offset location = startLocation + initialOffset + Offset.lerp(Offset.zero, offset, i / kMoveCount); - await sendEventToBinding(testPointer.move(location, timeStamp: Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(testPointer.move(location, timeStamp: Duration(microseconds: timeStamp.round())), result); timeStamp += timeStampDelta; - if (timeStamp - lastTimeStamp > frameInterval.inMilliseconds) { - await pump(Duration(milliseconds: (timeStamp - lastTimeStamp).truncate())); + if (timeStamp - lastTimeStamp > frameInterval.inMicroseconds) { + await pump(Duration(microseconds: (timeStamp - lastTimeStamp).truncate())); lastTimeStamp = timeStamp; } } - await sendEventToBinding(testPointer.up(timeStamp: Duration(milliseconds: timeStamp.round())), result); + await sendEventToBinding(testPointer.up(timeStamp: Duration(microseconds: timeStamp.round())), result); }); }
diff --git a/packages/flutter_test/test/controller_test.dart b/packages/flutter_test/test/controller_test.dart index 8bf9dda..afbffdf 100644 --- a/packages/flutter_test/test/controller_test.dart +++ b/packages/flutter_test/test/controller_test.dart
@@ -551,6 +551,33 @@ ); testWidgets( + 'WidgetTester.fling produces strictly monotonically increasing timestamps, ' + 'when given a large velocity', + (WidgetTester tester) async { + // Velocity trackers may misbehave if the `PointerMoveEvent`s' have the + // same timestamp. This is more likely to happen when the velocity tracker + // has a small sample size. + final List<Duration> logs = <Duration>[]; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Listener( + onPointerMove: (PointerMoveEvent event) => logs.add(event.timeStamp), + child: const Text('test'), + ), + ), + ); + + await tester.fling(find.text('test'), const Offset(0.0, -50.0), 10000.0); + await tester.pumpAndSettle(); + + for (int i = 0; i + 1 < logs.length; i += 1) { + expect(logs[i + 1], greaterThan(logs[i])); + } + }); + + testWidgets( 'ensureVisible: scrolls to make widget visible', (WidgetTester tester) async { await tester.pumpWidget(