Skip to content

Commit 59b9418

Browse files
authored
Scroll momentum builds on iOS with repeated flings (flutter#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 flutter#11988 * move static * Review
1 parent 2447f91 commit 59b9418

6 files changed

Lines changed: 173 additions & 38 deletions

File tree

packages/flutter/lib/src/widgets/scroll_activity.dart

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ abstract class ScrollActivity {
118118
/// [ScrollDirection.idle].
119119
bool get isScrolling;
120120

121+
/// If applicable, the velocity at which the scroll offset is currently
122+
/// independently changing (i.e. without external stimuli such as a dragging
123+
/// gestures) in logical pixels per second for this activity.
124+
double get velocity;
125+
121126
/// Called when the scroll view stops performing this activity.
122127
@mustCallSuper
123128
void dispose() {
@@ -148,6 +153,9 @@ class IdleScrollActivity extends ScrollActivity {
148153

149154
@override
150155
bool get isScrolling => false;
156+
157+
@override
158+
double get velocity => 0.0;
151159
}
152160

153161
/// Interface for holding a [Scrollable] stationary.
@@ -187,6 +195,9 @@ class HoldScrollActivity extends ScrollActivity implements ScrollHoldController
187195
@override
188196
bool get isScrolling => false;
189197

198+
@override
199+
double get velocity => 0.0;
200+
190201
@override
191202
void cancel() {
192203
delegate.goBallistic(0.0);
@@ -215,10 +226,13 @@ class ScrollDragController implements Drag {
215226
@required ScrollActivityDelegate delegate,
216227
@required DragStartDetails details,
217228
this.onDragCanceled,
229+
this.carriedVelocity,
218230
}) : assert(delegate != null),
219231
assert(details != null),
220232
_delegate = delegate,
221-
_lastDetails = details;
233+
_lastDetails = details,
234+
_retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
235+
_lastNonStationaryTimestamp = details.sourceTimeStamp;
222236

223237
/// The object that will actuate the scroll view as the user drags.
224238
ScrollActivityDelegate get delegate => _delegate;
@@ -227,6 +241,19 @@ class ScrollDragController implements Drag {
227241
/// Called when [dispose] is called.
228242
final VoidCallback onDragCanceled;
229243

244+
/// Velocity that was present from a previous [ScrollActivity] when this drag
245+
/// began.
246+
final double carriedVelocity;
247+
248+
Duration _lastNonStationaryTimestamp;
249+
bool _retainMomentum;
250+
251+
/// Maximum amount of time interval the drag can have consecutive stationary
252+
/// pointer update events before losing the momentum carried from a previous
253+
/// scroll activity.
254+
static const Duration momentumRetainStationaryThreshold =
255+
const Duration(milliseconds: 20);
256+
230257
bool get _reversed => axisDirectionIsReversed(delegate.axisDirection);
231258

232259
/// Updates the controller's link to the [ScrollActivityDelegate].
@@ -243,8 +270,17 @@ class ScrollDragController implements Drag {
243270
assert(details.primaryDelta != null);
244271
_lastDetails = details;
245272
double offset = details.primaryDelta;
246-
if (offset == 0.0)
273+
if (offset == 0.0) {
274+
if (_retainMomentum &&
275+
(details.sourceTimeStamp == null || // If drag event has no timestamp, we lose momentum.
276+
details.sourceTimeStamp - _lastNonStationaryTimestamp > momentumRetainStationaryThreshold )) {
277+
// If pointer is stationary for too long, we lose momentum.
278+
_retainMomentum = false;
279+
}
247280
return;
281+
} else {
282+
_lastNonStationaryTimestamp = details.sourceTimeStamp;
283+
}
248284
if (_reversed) // e.g. an AxisDirection.up scrollable
249285
offset = -offset;
250286
delegate.applyUserOffset(offset);
@@ -253,14 +289,18 @@ class ScrollDragController implements Drag {
253289
@override
254290
void end(DragEndDetails details) {
255291
assert(details.primaryVelocity != null);
256-
double velocity = details.primaryVelocity;
257-
if (_reversed) // e.g. an AxisDirection.up scrollable
258-
velocity = -velocity;
259-
_lastDetails = details;
260292
// We negate the velocity here because if the touch is moving downwards,
261293
// the scroll has to move upwards. It's the same reason that update()
262294
// above negates the delta before applying it to the scroll offset.
263-
delegate.goBallistic(-velocity);
295+
double velocity = -details.primaryVelocity;
296+
if (_reversed) // e.g. an AxisDirection.up scrollable
297+
velocity = -velocity;
298+
_lastDetails = details;
299+
300+
// Build momentum only if dragging in the same direction.
301+
if (_retainMomentum && velocity.sign == carriedVelocity.sign)
302+
velocity += carriedVelocity;
303+
delegate.goBallistic(velocity);
264304
}
265305

266306
@override
@@ -340,6 +380,11 @@ class DragScrollActivity extends ScrollActivity {
340380
@override
341381
bool get isScrolling => true;
342382

383+
// DragScrollActivity is not independently changing velocity yet
384+
// until the drag is ended.
385+
@override
386+
double get velocity => 0.0;
387+
343388
@override
344389
void dispose() {
345390
_controller = null;
@@ -383,8 +428,7 @@ class BallisticScrollActivity extends ScrollActivity {
383428
.whenComplete(_end); // won't trigger if we dispose _controller first
384429
}
385430

386-
/// The velocity at which the scroll offset is currently changing (in logical
387-
/// pixels per second).
431+
@override
388432
double get velocity => _controller.velocity;
389433

390434
AnimationController _controller;
@@ -491,8 +535,7 @@ class DrivenScrollActivity extends ScrollActivity {
491535
/// animation to stop before it reaches the end.
492536
Future<Null> get done => _completer.future;
493537

494-
/// The velocity at which the scroll offset is currently changing (in logical
495-
/// pixels per second).
538+
@override
496539
double get velocity => _controller.velocity;
497540

498541
void _tick() {

packages/flutter/lib/src/widgets/scroll_physics.dart

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ class ScrollPhysics {
195195
/// Scroll fling velocity magnitudes will be clamped to this value.
196196
double get maxFlingVelocity => parent?.maxFlingVelocity ?? kMaxFlingVelocity;
197197

198+
/// Returns the velocity carried on repeated flings.
199+
///
200+
/// The function is applied to the existing scroll velocity when another
201+
/// scroll drag is applied in the same direction.
202+
///
203+
/// By default, physics for platforms other than iOS doesn't carry momentum.
204+
double carriedMomentum(double existingVelocity) {
205+
if (parent == null)
206+
return 0.0;
207+
return parent.carriedMomentum(existingVelocity);
208+
}
209+
198210
@override
199211
String toString() {
200212
if (parent == null)
@@ -294,6 +306,26 @@ class BouncingScrollPhysics extends ScrollPhysics {
294306
// to trigger a fling.
295307
@override
296308
double get minFlingVelocity => kMinFlingVelocity * 2.0;
309+
310+
// Methodology:
311+
// 1- Use https://github.com/flutter/scroll_overlay to test with Flutter and
312+
// platform scroll views superimposed.
313+
// 2- Record incoming speed and make rapid flings in the test app.
314+
// 3- If the scrollables stopped overlapping at any moment, adjust the desired
315+
// output value of this function at that input speed.
316+
// 4- Feed new input/output set into a power curve fitter. Change function
317+
// and repeat from 2.
318+
// 5- Repeat from 2 with medium and slow flings.
319+
/// Momentum build-up function that mimics iOS's scroll speed increase with repeated flings.
320+
///
321+
/// The velocity of the last fling is not an important factor. Existing speed
322+
/// and (related) time since last fling are factors for the velocity transfer
323+
/// calculations.
324+
@override
325+
double carriedMomentum(double existingVelocity) {
326+
return existingVelocity.sign *
327+
math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0);
328+
}
297329
}
298330

299331
/// Scroll physics for environments that prevent the scroll offset from reaching
@@ -404,7 +436,6 @@ class AlwaysScrollableScrollPhysics extends ScrollPhysics {
404436
return new AlwaysScrollableScrollPhysics(parent: buildParent(ancestor));
405437
}
406438

407-
408439
@override
409440
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
410441
}

packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
7272
assert(activity != null);
7373
}
7474

75+
/// Velocity from a previous activity temporarily held by [hold] to potentially
76+
/// transfer to a next activity.
77+
double _heldPreviousVelocity = 0.0;
78+
7579
@override
7680
AxisDirection get axisDirection => context.axisDirection;
7781

@@ -112,6 +116,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
112116

113117
@override
114118
void beginActivity(ScrollActivity newActivity) {
119+
_heldPreviousVelocity = 0.0;
115120
if (newActivity == null)
116121
return;
117122
assert(newActivity.delegate == this);
@@ -222,12 +227,14 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
222227

223228
@override
224229
ScrollHoldController hold(VoidCallback holdCancelCallback) {
225-
final HoldScrollActivity activity = new HoldScrollActivity(
230+
final double previousVelocity = activity.velocity;
231+
final HoldScrollActivity holdActivity = new HoldScrollActivity(
226232
delegate: this,
227233
onHoldCanceled: holdCancelCallback,
228234
);
229-
beginActivity(activity);
230-
return activity;
235+
beginActivity(holdActivity);
236+
_heldPreviousVelocity = previousVelocity;
237+
return holdActivity;
231238
}
232239

233240
ScrollDragController _currentDrag;
@@ -238,6 +245,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
238245
delegate: this,
239246
details: details,
240247
onDragCanceled: onDragCanceled,
248+
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
241249
);
242250
beginActivity(new DragScrollActivity(this, drag));
243251
assert(_currentDrag == null);

packages/flutter/lib/src/widgets/scroll_simulation.dart

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,17 @@ class BouncingScrollSimulation extends Simulation {
5353
final double finalX = _frictionSimulation.finalX;
5454
if (velocity > 0.0 && finalX > trailingExtent) {
5555
_springTime = _frictionSimulation.timeAtX(trailingExtent);
56-
_springSimulation = _overscrollSimulation(trailingExtent, _frictionSimulation.dx(_springTime));
56+
_springSimulation = _overscrollSimulation(
57+
trailingExtent,
58+
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
59+
);
5760
assert(_springTime.isFinite);
5861
} else if (velocity < 0.0 && finalX < leadingExtent) {
5962
_springTime = _frictionSimulation.timeAtX(leadingExtent);
60-
_springSimulation = _underscrollSimulation(leadingExtent, _frictionSimulation.dx(_springTime));
63+
_springSimulation = _underscrollSimulation(
64+
leadingExtent,
65+
math.min(_frictionSimulation.dx(_springTime), maxSpringTransferVelocity),
66+
);
6167
assert(_springTime.isFinite);
6268
} else {
6369
_springTime = double.INFINITY;
@@ -66,6 +72,10 @@ class BouncingScrollSimulation extends Simulation {
6672
assert(_springTime != null);
6773
}
6874

75+
/// The maximum velocity that can be transfered from the inertia of a ballistic
76+
/// scroll into overscroll.
77+
static const double maxSpringTransferVelocity = 5000.0;
78+
6979
/// When [x] falls below this value the simulation switches from an internal friction
7080
/// model to a spring model which causes [x] to "spring" back to [leadingExtent].
7181
final double leadingExtent;

packages/flutter/test/widgets/linked_scroll_view_test.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,11 @@ class LinkedScrollActivity extends ScrollActivity {
229229
@override
230230
bool get isScrolling => true;
231231

232+
// LinkedScrollActivity is not self-driven but moved by calls to the [moveBy]
233+
// method.
234+
@override
235+
double get velocity => 0.0;
236+
232237
double moveBy(double delta) {
233238
assert(drivers.isNotEmpty);
234239
ScrollDirection commonDirection;

packages/flutter/test/widgets/scrollable_test.dart

Lines changed: 59 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ double getScrollOffset(WidgetTester tester) {
2828
return viewport.offset.pixels;
2929
}
3030

31+
double getScrollVelocity(WidgetTester tester) {
32+
final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
33+
final ScrollPosition position = viewport.offset;
34+
// Access for test only.
35+
return position.activity.velocity; // ignore: INVALID_USE_OF_PROTECTED_MEMBER
36+
}
37+
3138
void resetScrollOffset(WidgetTester tester) {
3239
final RenderViewport viewport = tester.renderObject(find.byType(Viewport));
3340
final ScrollPosition position = viewport.offset;
@@ -57,42 +64,73 @@ void main() {
5764
expect(result1, lessThan(result2)); // iOS (result2) is slipperier than Android (result1)
5865
});
5966

60-
testWidgets('Flings on different platforms', (WidgetTester tester) async {
67+
testWidgets('Holding scroll', (WidgetTester tester) async {
68+
await pumpTest(tester, TargetPlatform.iOS);
69+
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0));
70+
expect(getScrollOffset(tester), -200.0);
71+
await tester.pump(); // trigger ballistic
72+
await tester.pump(const Duration(milliseconds: 10));
73+
expect(getScrollOffset(tester), greaterThan(-200.0));
74+
expect(getScrollOffset(tester), lessThan(0.0));
75+
final double position = getScrollOffset(tester);
76+
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
77+
expect(await tester.pumpAndSettle(), 1);
78+
expect(getScrollOffset(tester), position);
79+
await gesture.up();
80+
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
81+
expect(getScrollOffset(tester), 0.0);
82+
});
83+
84+
testWidgets('Repeated flings builds momentum on iOS', (WidgetTester tester) async {
6185
await pumpTest(tester, TargetPlatform.iOS);
6286
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
63-
expect(getScrollOffset(tester), dragOffset);
6487
await tester.pump(); // trigger fling
65-
expect(getScrollOffset(tester), dragOffset);
66-
await tester.pump(const Duration(seconds: 5));
67-
final double result1 = getScrollOffset(tester);
88+
await tester.pump(const Duration(milliseconds: 10));
89+
// Repeat the exact same motion.
90+
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
91+
await tester.pump();
92+
// On iOS, the velocity will be larger than the velocity of the last fling by a
93+
// non-trivial amount.
94+
expect(getScrollVelocity(tester), greaterThan(1100.0));
6895

6996
resetScrollOffset(tester);
7097

7198
await pumpTest(tester, TargetPlatform.android);
7299
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
73-
expect(getScrollOffset(tester), dragOffset);
74100
await tester.pump(); // trigger fling
75-
expect(getScrollOffset(tester), dragOffset);
76-
await tester.pump(const Duration(seconds: 5));
77-
final double result2 = getScrollOffset(tester);
101+
await tester.pump(const Duration(milliseconds: 10));
102+
// Repeat the exact same motion.
103+
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
104+
await tester.pump();
105+
// On Android, there is no momentum build. The final velocity is the same as the
106+
// velocity of the last fling.
107+
expect(getScrollVelocity(tester), moreOrLessEquals(1000.0));
108+
});
78109

79-
expect(result1, greaterThan(result2)); // iOS (result1) is slipperier than Android (result2)
110+
testWidgets('No iOS momentum build with flings in opposite directions', (WidgetTester tester) async {
111+
await pumpTest(tester, TargetPlatform.iOS);
112+
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
113+
await tester.pump(); // trigger fling
114+
await tester.pump(const Duration(milliseconds: 10));
115+
// Repeat the exact same motion in the opposite direction.
116+
await tester.fling(find.byType(Viewport), const Offset(0.0, dragOffset), 1000.0);
117+
await tester.pump();
118+
// The only applied velocity to the scrollable is the second fling that was in the
119+
// opposite direction.
120+
expect(getScrollVelocity(tester), greaterThan(-1000.0));
121+
expect(getScrollVelocity(tester), lessThan(0.0));
80122
});
81123

82-
testWidgets('Holding scroll', (WidgetTester tester) async {
124+
testWidgets('No iOS momentum kept on hold gestures', (WidgetTester tester) async {
83125
await pumpTest(tester, TargetPlatform.iOS);
84-
await tester.drag(find.byType(Viewport), const Offset(0.0, 200.0));
85-
expect(getScrollOffset(tester), -200.0);
86-
await tester.pump(); // trigger ballistic
126+
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
127+
await tester.pump(); // trigger fling
87128
await tester.pump(const Duration(milliseconds: 10));
88-
expect(getScrollOffset(tester), greaterThan(-200.0));
89-
expect(getScrollOffset(tester), lessThan(0.0));
90-
final double position = getScrollOffset(tester);
129+
expect(getScrollVelocity(tester), greaterThan(0.0));
91130
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
92-
expect(await tester.pumpAndSettle(), 1);
93-
expect(getScrollOffset(tester), position);
131+
await tester.pump(const Duration(milliseconds: 40));
94132
await gesture.up();
95-
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
96-
expect(getScrollOffset(tester), 0.0);
133+
// After a hold longer than 2 frames, previous velocity is lost.
134+
expect(getScrollVelocity(tester), 0.0);
97135
});
98136
}

0 commit comments

Comments
 (0)