mirror of
https://github.com/flutter/flutter
synced 2024-10-13 19:52:53 +00:00
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:
parent
2447f91844
commit
59b9418540
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue