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(