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
This commit is contained in:
xster 2017-09-19 17:39:04 -07:00 committed by GitHub
parent 2447f91844
commit 59b9418540
6 changed files with 174 additions and 39 deletions

View file

@ -118,6 +118,11 @@ abstract class ScrollActivity {
/// [ScrollDirection.idle].
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.
@mustCallSuper
void dispose() {
@ -148,6 +153,9 @@ class IdleScrollActivity extends ScrollActivity {
@override
bool get isScrolling => false;
@override
double get velocity => 0.0;
}
/// Interface for holding a [Scrollable] stationary.
@ -187,6 +195,9 @@ class HoldScrollActivity extends ScrollActivity implements ScrollHoldController
@override
bool get isScrolling => false;
@override
double get velocity => 0.0;
@override
void cancel() {
delegate.goBallistic(0.0);
@ -215,10 +226,13 @@ class ScrollDragController implements Drag {
@required ScrollActivityDelegate delegate,
@required DragStartDetails details,
this.onDragCanceled,
this.carriedVelocity,
}) : assert(delegate != null),
assert(details != null),
_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.
ScrollActivityDelegate get delegate => _delegate;
@ -227,6 +241,19 @@ class ScrollDragController implements Drag {
/// Called when [dispose] is called.
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);
/// Updates the controller's link to the [ScrollActivityDelegate].
@ -243,8 +270,17 @@ class ScrollDragController implements Drag {
assert(details.primaryDelta != null);
_lastDetails = details;
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;
} else {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
if (_reversed) // e.g. an AxisDirection.up scrollable
offset = -offset;
delegate.applyUserOffset(offset);
@ -253,14 +289,18 @@ class ScrollDragController implements Drag {
@override
void end(DragEndDetails details) {
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,
// the scroll has to move upwards. It's the same reason that update()
// 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
@ -340,6 +380,11 @@ class DragScrollActivity extends ScrollActivity {
@override
bool get isScrolling => true;
// DragScrollActivity is not independently changing velocity yet
// until the drag is ended.
@override
double get velocity => 0.0;
@override
void dispose() {
_controller = null;
@ -383,8 +428,7 @@ class BallisticScrollActivity extends ScrollActivity {
.whenComplete(_end); // won't trigger if we dispose _controller first
}
/// The velocity at which the scroll offset is currently changing (in logical
/// pixels per second).
@override
double get velocity => _controller.velocity;
AnimationController _controller;
@ -491,8 +535,7 @@ class DrivenScrollActivity extends ScrollActivity {
/// animation to stop before it reaches the end.
Future<Null> get done => _completer.future;
/// The velocity at which the scroll offset is currently changing (in logical
/// pixels per second).
@override
double get velocity => _controller.velocity;
void _tick() {

View file

@ -195,6 +195,18 @@ class ScrollPhysics {
/// Scroll fling velocity magnitudes will be clamped to this value.
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
String toString() {
if (parent == null)
@ -294,6 +306,26 @@ class BouncingScrollPhysics extends ScrollPhysics {
// to trigger a fling.
@override
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
@ -404,7 +436,6 @@ class AlwaysScrollableScrollPhysics extends ScrollPhysics {
return new AlwaysScrollableScrollPhysics(parent: buildParent(ancestor));
}
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
}

View file

@ -72,6 +72,10 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
assert(activity != null);
}
/// Velocity from a previous activity temporarily held by [hold] to potentially
/// transfer to a next activity.
double _heldPreviousVelocity = 0.0;
@override
AxisDirection get axisDirection => context.axisDirection;
@ -112,6 +116,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
@override
void beginActivity(ScrollActivity newActivity) {
_heldPreviousVelocity = 0.0;
if (newActivity == null)
return;
assert(newActivity.delegate == this);
@ -222,12 +227,14 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
@override
ScrollHoldController hold(VoidCallback holdCancelCallback) {
final HoldScrollActivity activity = new HoldScrollActivity(
final double previousVelocity = activity.velocity;
final HoldScrollActivity holdActivity = new HoldScrollActivity(
delegate: this,
onHoldCanceled: holdCancelCallback,
);
beginActivity(activity);
return activity;
beginActivity(holdActivity);
_heldPreviousVelocity = previousVelocity;
return holdActivity;
}
ScrollDragController _currentDrag;
@ -238,6 +245,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
delegate: this,
details: details,
onDragCanceled: onDragCanceled,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
);
beginActivity(new DragScrollActivity(this, drag));
assert(_currentDrag == null);

View file

@ -53,11 +53,17 @@ class BouncingScrollSimulation extends Simulation {
final double finalX = _frictionSimulation.finalX;
if (velocity > 0.0 && finalX > trailingExtent) {
_springTime = _frictionSimulation.timeAtX(trailingExtent);
_springSimulation = _overscrollSimulation(trailingExtent, _frictionSimulation.dx(_springTime));
_springSimulation = _overscrollSimulation(
trailingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite);
} else if (velocity < 0.0 && finalX < leadingExtent) {
_springTime = _frictionSimulation.timeAtX(leadingExtent);
_springSimulation = _underscrollSimulation(leadingExtent, _frictionSimulation.dx(_springTime));
_springSimulation = _underscrollSimulation(
leadingExtent,
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
);
assert(_springTime.isFinite);
} else {
_springTime = double.INFINITY;
@ -66,6 +72,10 @@ class BouncingScrollSimulation extends Simulation {
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
/// model to a spring model which causes [x] to "spring" back to [leadingExtent].
final double leadingExtent;

View file

@ -229,6 +229,11 @@ class LinkedScrollActivity extends ScrollActivity {
@override
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) {
assert(drivers.isNotEmpty);
ScrollDirection commonDirection;

View file

@ -28,6 +28,13 @@ double getScrollOffset(WidgetTester tester) {
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) {
final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
final ScrollPosition position = viewport.offset;
@ -57,28 +64,6 @@ void main() {
expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1)
});
testWidgets('Flings on different platforms', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result1 = getScrollOffset(tester);
resetScrollOffset(tester);
await pumpTest(tester, TargetPlatform.android);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset);
await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset);
await tester.pump(const Duration(seconds: 5));
final double result2 = getScrollOffset(tester);
expect(result1, greaterThan(result2)); // iOS (result1) is slipperier than Android (result2)
});
testWidgets('Holding scroll', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0));
@ -95,4 +80,57 @@ void main() {
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 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.
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);
await pumpTest(tester, TargetPlatform.android);
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.
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));
});
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('No iOS momentum kept on hold gestures', (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));
expect(getScrollVelocity(tester), greaterThan(0.0));
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await tester.pump(const Duration(milliseconds: 40));
await gesture.up();
// After a hold longer than 2 frames, previous velocity is lost.
expect(getScrollVelocity(tester), 0.0);
});
}