Commit 59b94185 authored by xster's avatar xster Committed by GitHub

Scroll momentum builds on iOS with repeated flings (#11685)

* Record original pointer event timestamp

* review

* review

* review

* Matched motions with iOS. Didn’t add overscroll spring clamps and fix tests yet.

* clamp max overscroll transfer

* Add test

* review notes, moved things around

* remove function passing indirection

* review

* Replace stopwatch with timestamp from #11988

* move static

* Review
parent 2447f918
...@@ -118,6 +118,11 @@ abstract class ScrollActivity { ...@@ -118,6 +118,11 @@ abstract class ScrollActivity {
/// [ScrollDirection.idle]. /// [ScrollDirection.idle].
bool get isScrolling; bool get isScrolling;
/// If applicable, the velocity at which the scroll offset is currently
/// independently changing (i.e. without external stimuli such as a dragging
/// gestures) in logical pixels per second for this activity.
double get velocity;
/// Called when the scroll view stops performing this activity. /// Called when the scroll view stops performing this activity.
@mustCallSuper @mustCallSuper
void dispose() { void dispose() {
...@@ -148,6 +153,9 @@ class IdleScrollActivity extends ScrollActivity { ...@@ -148,6 +153,9 @@ class IdleScrollActivity extends ScrollActivity {
@override @override
bool get isScrolling => false; bool get isScrolling => false;
@override
double get velocity => 0.0;
} }
/// Interface for holding a [Scrollable] stationary. /// Interface for holding a [Scrollable] stationary.
...@@ -187,6 +195,9 @@ class HoldScrollActivity extends ScrollActivity implements ScrollHoldController ...@@ -187,6 +195,9 @@ class HoldScrollActivity extends ScrollActivity implements ScrollHoldController
@override @override
bool get isScrolling => false; bool get isScrolling => false;
@override
double get velocity => 0.0;
@override @override
void cancel() { void cancel() {
delegate.goBallistic(0.0); delegate.goBallistic(0.0);
...@@ -215,10 +226,13 @@ class ScrollDragController implements Drag { ...@@ -215,10 +226,13 @@ class ScrollDragController implements Drag {
@required ScrollActivityDelegate delegate, @required ScrollActivityDelegate delegate,
@required DragStartDetails details, @required DragStartDetails details,
this.onDragCanceled, this.onDragCanceled,
this.carriedVelocity,
}) : assert(delegate != null), }) : assert(delegate != null),
assert(details != null), assert(details != null),
_delegate = delegate, _delegate = delegate,
_lastDetails = details; _lastDetails = details,
_retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
_lastNonStationaryTimestamp = details.sourceTimeStamp;
/// The object that will actuate the scroll view as the user drags. /// The object that will actuate the scroll view as the user drags.
ScrollActivityDelegate get delegate => _delegate; ScrollActivityDelegate get delegate => _delegate;
...@@ -227,6 +241,19 @@ class ScrollDragController implements Drag { ...@@ -227,6 +241,19 @@ class ScrollDragController implements Drag {
/// Called when [dispose] is called. /// Called when [dispose] is called.
final VoidCallback onDragCanceled; final VoidCallback onDragCanceled;
/// Velocity that was present from a previous [ScrollActivity] when this drag
/// began.
final double carriedVelocity;
Duration _lastNonStationaryTimestamp;
bool _retainMomentum;
/// 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 =
const Duration(milliseconds: 20);
bool get _reversed => axisDirectionIsReversed(delegate.axisDirection); bool get _reversed => axisDirectionIsReversed(delegate.axisDirection);
/// Updates the controller's link to the [ScrollActivityDelegate]. /// Updates the controller's link to the [ScrollActivityDelegate].
...@@ -243,8 +270,17 @@ class ScrollDragController implements Drag { ...@@ -243,8 +270,17 @@ class ScrollDragController implements Drag {
assert(details.primaryDelta != null); assert(details.primaryDelta != null);
_lastDetails = details; _lastDetails = details;
double offset = details.primaryDelta; double offset = details.primaryDelta;
if (offset == 0.0) 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; return;
} else {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
if (_reversed) // e.g. an AxisDirection.up scrollable if (_reversed) // e.g. an AxisDirection.up scrollable
offset = -offset; offset = -offset;
delegate.applyUserOffset(offset); delegate.applyUserOffset(offset);
...@@ -253,14 +289,18 @@ class ScrollDragController implements Drag { ...@@ -253,14 +289,18 @@ class ScrollDragController implements Drag {
@override @override
void end(DragEndDetails details) { void end(DragEndDetails details) {
assert(details.primaryVelocity != null); assert(details.primaryVelocity != null);
double velocity = details.primaryVelocity;
if (_reversed) // e.g. an AxisDirection.up scrollable
velocity = -velocity;
_lastDetails = details;
// We negate the velocity here because if the touch is moving downwards, // We negate the velocity here because if the touch is moving downwards,
// the scroll has to move upwards. It's the same reason that update() // the scroll has to move upwards. It's the same reason that update()
// above negates the delta before applying it to the scroll offset. // above negates the delta before applying it to the scroll offset.
delegate.goBallistic(-velocity); double velocity = -details.primaryVelocity;
if (_reversed) // e.g. an AxisDirection.up scrollable
velocity = -velocity;
_lastDetails = details;
// Build momentum only if dragging in the same direction.
if (_retainMomentum && velocity.sign == carriedVelocity.sign)
velocity += carriedVelocity;
delegate.goBallistic(velocity);
} }
@override @override
...@@ -340,6 +380,11 @@ class DragScrollActivity extends ScrollActivity { ...@@ -340,6 +380,11 @@ class DragScrollActivity extends ScrollActivity {
@override @override
bool get isScrolling => true; bool get isScrolling => true;
// DragScrollActivity is not independently changing velocity yet
// until the drag is ended.
@override
double get velocity => 0.0;
@override @override
void dispose() { void dispose() {
_controller = null; _controller = null;
...@@ -383,8 +428,7 @@ class BallisticScrollActivity extends ScrollActivity { ...@@ -383,8 +428,7 @@ class BallisticScrollActivity extends ScrollActivity {
.whenComplete(_end); // won't trigger if we dispose _controller first .whenComplete(_end); // won't trigger if we dispose _controller first
} }
/// The velocity at which the scroll offset is currently changing (in logical @override
/// pixels per second).
double get velocity => _controller.velocity; double get velocity => _controller.velocity;
AnimationController _controller; AnimationController _controller;
...@@ -491,8 +535,7 @@ class DrivenScrollActivity extends ScrollActivity { ...@@ -491,8 +535,7 @@ class DrivenScrollActivity extends ScrollActivity {
/// animation to stop before it reaches the end. /// animation to stop before it reaches the end.
Future<Null> get done => _completer.future; Future<Null> get done => _completer.future;
/// The velocity at which the scroll offset is currently changing (in logical @override
/// pixels per second).
double get velocity => _controller.velocity; double get velocity => _controller.velocity;
void _tick() { void _tick() {
......
...@@ -195,6 +195,18 @@ class ScrollPhysics { ...@@ -195,6 +195,18 @@ class ScrollPhysics {
/// Scroll fling velocity magnitudes will be clamped to this value. /// Scroll fling velocity magnitudes will be clamped to this value.
double get maxFlingVelocity => parent?.maxFlingVelocity ?? kMaxFlingVelocity; double get maxFlingVelocity => parent?.maxFlingVelocity ?? kMaxFlingVelocity;
/// Returns the velocity carried on repeated flings.
///
/// The function is applied to the existing scroll velocity when another
/// scroll drag is applied in the same direction.
///
/// By default, physics for platforms other than iOS doesn't carry momentum.
double carriedMomentum(double existingVelocity) {
if (parent == null)
return 0.0;
return parent.carriedMomentum(existingVelocity);
}
@override @override
String toString() { String toString() {
if (parent == null) if (parent == null)
...@@ -294,6 +306,26 @@ class BouncingScrollPhysics extends ScrollPhysics { ...@@ -294,6 +306,26 @@ class BouncingScrollPhysics extends ScrollPhysics {
// to trigger a fling. // to trigger a fling.
@override @override
double get minFlingVelocity => kMinFlingVelocity * 2.0; double get minFlingVelocity => kMinFlingVelocity * 2.0;
// Methodology:
// 1- Use https://github.com/flutter/scroll_overlay to test with Flutter and
// platform scroll views superimposed.
// 2- Record incoming speed and make rapid flings in the test app.
// 3- If the scrollables stopped overlapping at any moment, adjust the desired
// output value of this function at that input speed.
// 4- Feed new input/output set into a power curve fitter. Change function
// and repeat from 2.
// 5- Repeat from 2 with medium and slow flings.
/// Momentum build-up function that mimics iOS's scroll speed increase with repeated flings.
///
/// The velocity of the last fling is not an important factor. Existing speed
/// and (related) time since last fling are factors for the velocity transfer
/// calculations.
@override
double carriedMomentum(double existingVelocity) {
return existingVelocity.sign *
math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0);
}
} }
/// Scroll physics for environments that prevent the scroll offset from reaching /// Scroll physics for environments that prevent the scroll offset from reaching
...@@ -404,7 +436,6 @@ class AlwaysScrollableScrollPhysics extends ScrollPhysics { ...@@ -404,7 +436,6 @@ class AlwaysScrollableScrollPhysics extends ScrollPhysics {
return new AlwaysScrollableScrollPhysics(parent: buildParent(ancestor)); return new AlwaysScrollableScrollPhysics(parent: buildParent(ancestor));
} }
@override @override
bool shouldAcceptUserOffset(ScrollMetrics position) => true; bool shouldAcceptUserOffset(ScrollMetrics position) => true;
} }
......
...@@ -72,6 +72,10 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ...@@ -72,6 +72,10 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
assert(activity != null); assert(activity != null);
} }
/// Velocity from a previous activity temporarily held by [hold] to potentially
/// transfer to a next activity.
double _heldPreviousVelocity = 0.0;
@override @override
AxisDirection get axisDirection => context.axisDirection; AxisDirection get axisDirection => context.axisDirection;
...@@ -112,6 +116,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ...@@ -112,6 +116,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
@override @override
void beginActivity(ScrollActivity newActivity) { void beginActivity(ScrollActivity newActivity) {
_heldPreviousVelocity = 0.0;
if (newActivity == null) if (newActivity == null)
return; return;
assert(newActivity.delegate == this); assert(newActivity.delegate == this);
...@@ -222,12 +227,14 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ...@@ -222,12 +227,14 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
@override @override
ScrollHoldController hold(VoidCallback holdCancelCallback) { ScrollHoldController hold(VoidCallback holdCancelCallback) {
final HoldScrollActivity activity = new HoldScrollActivity( final double previousVelocity = activity.velocity;
final HoldScrollActivity holdActivity = new HoldScrollActivity(
delegate: this, delegate: this,
onHoldCanceled: holdCancelCallback, onHoldCanceled: holdCancelCallback,
); );
beginActivity(activity); beginActivity(holdActivity);
return activity; _heldPreviousVelocity = previousVelocity;
return holdActivity;
} }
ScrollDragController _currentDrag; ScrollDragController _currentDrag;
...@@ -238,6 +245,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ...@@ -238,6 +245,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
delegate: this, delegate: this,
details: details, details: details,
onDragCanceled: onDragCanceled, onDragCanceled: onDragCanceled,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
); );
beginActivity(new DragScrollActivity(this, drag)); beginActivity(new DragScrollActivity(this, drag));
assert(_currentDrag == null); assert(_currentDrag == null);
......
...@@ -53,11 +53,17 @@ class BouncingScrollSimulation extends Simulation { ...@@ -53,11 +53,17 @@ class BouncingScrollSimulation extends Simulation {
final double finalX = _frictionSimulation.finalX; final double finalX = _frictionSimulation.finalX;
if (velocity > 0.0 && finalX > trailingExtent) { if (velocity > 0.0 && finalX > trailingExtent) {
_springTime = _frictionSimulation.timeAtX(trailingExtent); _springTime = _frictionSimulation.timeAtX(trailingExtent);
_springSimulation = _overscrollSimulation(trailingExtent, _frictionSimulation.dx(_springTime)); _springSimulation = _overscrollSimulation(
trailingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite); assert(_springTime.isFinite);
} else if (velocity < 0.0 && finalX < leadingExtent) { } else if (velocity < 0.0 && finalX < leadingExtent) {
_springTime = _frictionSimulation.timeAtX(leadingExtent); _springTime = _frictionSimulation.timeAtX(leadingExtent);
_springSimulation = _underscrollSimulation(leadingExtent, _frictionSimulation.dx(_springTime)); _springSimulation = _underscrollSimulation(
leadingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite); assert(_springTime.isFinite);
} else { } else {
_springTime = double.INFINITY; _springTime = double.INFINITY;
...@@ -66,6 +72,10 @@ class BouncingScrollSimulation extends Simulation { ...@@ -66,6 +72,10 @@ class BouncingScrollSimulation extends Simulation {
assert(_springTime != null); assert(_springTime != null);
} }
/// The maximum velocity that can be transfered from the inertia of a ballistic
/// scroll into overscroll.
static const double maxSpringTransferVelocity = 5000.0;
/// When [x] falls below this value the simulation switches from an internal friction /// When [x] falls below this value the simulation switches from an internal friction
/// model to a spring model which causes [x] to "spring" back to [leadingExtent]. /// model to a spring model which causes [x] to "spring" back to [leadingExtent].
final double leadingExtent; final double leadingExtent;
......
...@@ -229,6 +229,11 @@ class LinkedScrollActivity extends ScrollActivity { ...@@ -229,6 +229,11 @@ class LinkedScrollActivity extends ScrollActivity {
@override @override
bool get isScrolling => true; bool get isScrolling => true;
// LinkedScrollActivity is not self-driven but moved by calls to the [moveBy]
// method.
@override
double get velocity => 0.0;
double moveBy(double delta) { double moveBy(double delta) {
assert(drivers.isNotEmpty); assert(drivers.isNotEmpty);
ScrollDirection commonDirection; ScrollDirection commonDirection;
......
...@@ -28,6 +28,13 @@ double getScrollOffset(WidgetTester tester) { ...@@ -28,6 +28,13 @@ double getScrollOffset(WidgetTester tester) {
return viewport.offset.pixels; return viewport.offset.pixels;
} }
double getScrollVelocity(WidgetTester tester) {
final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
final ScrollPosition position = viewport.offset;
// Access for test only.
return position.activity.velocity; // ignore: INVALID_USE_OF_PROTECTED_MEMBER
}
void resetScrollOffset(WidgetTester tester) { void resetScrollOffset(WidgetTester tester) {
final RenderViewport viewport = tester.renderObject(find.byType(Viewport)); final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
final ScrollPosition position = viewport.offset; final ScrollPosition position = viewport.offset;
...@@ -57,42 +64,73 @@ void main() { ...@@ -57,42 +64,73 @@ void main() {
expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1) expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1)
}); });
testWidgets('Flings on different platforms', (WidgetTester tester) async { testWidgets('Holding scroll', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0));
expect(getScrollOffset(tester), -200.0);
await tester.pump(); // trigger ballistic
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 TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
expect(await tester.pumpAndSettle(), 1);
expect(getScrollOffset(tester), position);
await gesture.up();
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
expect(getScrollOffset(tester), 0.0);
});
testWidgets('Repeated flings builds momentum on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset); await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(seconds: 5)); // Repeat the exact same motion.
final double result1 = getScrollOffset(tester); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
await tester.pump();
// On iOS, the velocity will be larger than the velocity of the last fling by a
// non-trivial amount.
expect(getScrollVelocity(tester), greaterThan(1100.0));
resetScrollOffset(tester); resetScrollOffset(tester);
await pumpTest(tester, TargetPlatform.android); await pumpTest(tester, TargetPlatform.android);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset); await tester.pump(const Duration(milliseconds: 10));
await tester.pump(const Duration(seconds: 5)); // Repeat the exact same motion.
final double result2 = getScrollOffset(tester); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
await tester.pump();
// On Android, there is no momentum build. The final velocity is the same as the
// velocity of the last fling.
expect(getScrollVelocity(tester), moreOrLessEquals(1000.0));
});
expect(result1, greaterThan(result2)); // iOS (result1) is slipperier than Android (result2) testWidgets('No iOS momentum build with flings in opposite directions', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
await tester.pump(); // trigger fling
await tester.pump(const Duration(milliseconds: 10));
// Repeat the exact same motion in the opposite direction.
await tester.fling(find.byType(Viewport), const Offset(0.0, dragOffset), 1000.0);
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));
}); });
testWidgets('Holding scroll', (WidgetTester tester) async { testWidgets('No iOS momentum kept on hold gestures', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0)); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), -200.0); await tester.pump(); // trigger fling
await tester.pump(); // trigger ballistic
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(getScrollOffset(tester), greaterThan(-200.0)); expect(getScrollVelocity(tester), greaterThan(0.0));
expect(getScrollOffset(tester), lessThan(0.0));
final double position = getScrollOffset(tester);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
expect(await tester.pumpAndSettle(), 1); await tester.pump(const Duration(milliseconds: 40));
expect(getScrollOffset(tester), position);
await gesture.up(); await gesture.up();
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); // After a hold longer than 2 frames, previous velocity is lost.
expect(getScrollOffset(tester), 0.0); expect(getScrollVelocity(tester), 0.0);
}); });
} }
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