Unverified Commit cea055ef authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Make Scrollable's free scroll initial velocity matches that of iOS (#60501)

parent 54f21ec7
......@@ -5,7 +5,7 @@ window to see the device logs, then, in a different window, run any of
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
......
......@@ -11,26 +11,31 @@ const int _kNumIters = 10000;
void main() {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
final VelocityTracker tracker = VelocityTracker();
final BenchmarkResultPrinter printer = BenchmarkResultPrinter();
final List<VelocityTracker> trackers = <VelocityTracker>[VelocityTracker(), IOSScrollViewFlingVelocityTracker()];
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();
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',
);
}
watch.stop();
final BenchmarkResultPrinter printer = BenchmarkResultPrinter();
printer.addResult(
description: 'Velocity tracker',
value: watch.elapsedMicroseconds / _kNumIters,
unit: 'µs per iteration',
name: 'velocity_tracker_iteration',
);
printer.printToStdout();
}
......@@ -34,6 +34,8 @@ typedef GestureDragEndCallback = void Function(DragEndDetails details);
/// See [DragGestureRecognizer.onCancel].
typedef GestureDragCancelCallback = void Function();
typedef GestureVelocityTrackerBuilder = VelocityTracker Function(PointerEvent event);
/// Recognizes movement.
///
/// In contrast to [MultiDragGestureRecognizer], [DragGestureRecognizer]
......@@ -64,9 +66,11 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
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 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
/// 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 @@ abstract class DragGestureRecognizer extends OneSequenceGestureRecognizer {
@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);
......
......@@ -192,8 +192,8 @@ class VelocityTracker {
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 @@ class VelocityTracker {
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,
);
}
}
}
......@@ -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 @@ class ScrollBehavior {
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());
......
......@@ -507,6 +507,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior;
},
),
......@@ -526,6 +527,7 @@ class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, R
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior;
},
),
......
......@@ -78,4 +78,71 @@ void main() {
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);
}
}
});
}
......@@ -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 @@ class TestScrollBehavior extends ScrollBehavior {
@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 @@ void main() {
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 @@ void main() {
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));
......
......@@ -182,8 +182,7 @@ void main() {
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 @@ void main() {
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));
......
......@@ -380,25 +380,25 @@ abstract class WidgetController {
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);
});
}
......
......@@ -550,6 +550,33 @@ void main() {
},
);
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 {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment