Unverified Commit 738e62cb authored by xster's avatar xster Committed by GitHub

Let iOS have a minimum scroll movement threshold to break before motion starts (#13166)

* Add a minimum distance that needs breaking on iOS each time scrolls stopped.

* Testing and tests

* tweak docs

* review
parent 42ca92c7
...@@ -227,12 +227,18 @@ class ScrollDragController implements Drag { ...@@ -227,12 +227,18 @@ class ScrollDragController implements Drag {
@required DragStartDetails details, @required DragStartDetails details,
this.onDragCanceled, this.onDragCanceled,
this.carriedVelocity, this.carriedVelocity,
this.motionStartDistanceThreshold,
}) : assert(delegate != null), }) : assert(delegate != null),
assert(details != null), assert(details != null),
assert(
motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0,
'motionStartDistanceThreshold must be a positive number or null'
),
_delegate = delegate, _delegate = delegate,
_lastDetails = details, _lastDetails = details,
_retainMomentum = carriedVelocity != null && carriedVelocity != 0.0, _retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
_lastNonStationaryTimestamp = details.sourceTimeStamp; _lastNonStationaryTimestamp = details.sourceTimeStamp,
_offsetSinceLastStop = motionStartDistanceThreshold == null ? null : 0.0;
/// 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;
...@@ -245,15 +251,27 @@ class ScrollDragController implements Drag { ...@@ -245,15 +251,27 @@ class ScrollDragController implements Drag {
/// began. /// began.
final double carriedVelocity; final double carriedVelocity;
/// Amount of pixels in either direction the drag has to move by to start
/// scroll movement again after each time scrolling came to a stop.
final double motionStartDistanceThreshold;
Duration _lastNonStationaryTimestamp; Duration _lastNonStationaryTimestamp;
bool _retainMomentum; bool _retainMomentum;
/// Null if already in motion or has no [motionStartDistanceThreshold].
double _offsetSinceLastStop;
/// Maximum amount of time interval the drag can have consecutive stationary /// Maximum amount of time interval the drag can have consecutive stationary
/// pointer update events before losing the momentum carried from a previous /// pointer update events before losing the momentum carried from a previous
/// scroll activity. /// scroll activity.
static const Duration momentumRetainStationaryThreshold = static const Duration momentumRetainStationaryDurationThreshold =
const Duration(milliseconds: 20); const Duration(milliseconds: 20);
/// Maximum amount of time interval the drag can have consecutive stationary
/// pointer update events before needing to break the
/// [motionStartDistanceThreshold] to start motion again.
static const Duration motionStoppedDurationThreshold =
const Duration(milliseconds: 50);
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].
...@@ -265,22 +283,67 @@ class ScrollDragController implements Drag { ...@@ -265,22 +283,67 @@ class ScrollDragController implements Drag {
_delegate = value; _delegate = value;
} }
/// Determines whether to lose the existing incoming velocity when starting
/// the drag.
void _maybeLoseMomentum(double offset, Duration timestamp) {
if (_retainMomentum &&
offset == 0.0 &&
(timestamp == null || // If drag event has no timestamp, we lose momentum.
timestamp - _lastNonStationaryTimestamp > momentumRetainStationaryDurationThreshold)) {
// If pointer is stationary for too long, we lose momentum.
_retainMomentum = false;
}
}
/// If a motion start threshold exists, determine whether the threshold is
/// reached to start applying position offset.
///
/// Returns false either way if there's no offset.
bool _breakMotionStartThreshold(double offset, Duration timestamp) {
if (timestamp == null) {
// If we can't track time, we can't apply thresholds.
// May be null for proxied drags like via accessibility.
return true;
}
if (offset == 0.0) {
if (motionStartDistanceThreshold != null &&
_offsetSinceLastStop == null &&
timestamp - _lastNonStationaryTimestamp > motionStoppedDurationThreshold) {
// Enforce a new threshold.
_offsetSinceLastStop = 0.0;
}
// Not moving can't break threshold.
return false;
} else {
if (_offsetSinceLastStop == null) {
// Already in motion. Allow transparent offset transmission.
return true;
} else {
_offsetSinceLastStop += offset;
if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) {
// Threshold broken.
_offsetSinceLastStop = null;
return true;
} else {
return false;
}
}
}
}
@override @override
void update(DragUpdateDetails details) { void update(DragUpdateDetails details) {
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) {
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; _lastNonStationaryTimestamp = details.sourceTimeStamp;
} }
_maybeLoseMomentum(offset, details.sourceTimeStamp);
if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) {
return;
}
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);
......
...@@ -207,6 +207,12 @@ class ScrollPhysics { ...@@ -207,6 +207,12 @@ class ScrollPhysics {
return parent.carriedMomentum(existingVelocity); return parent.carriedMomentum(existingVelocity);
} }
/// The minimum amount of pixel distance drags must move by to start motion
/// the first time or after each time the drag motion stopped.
///
/// If null, no minimum threshold is enforced.
double get dragStartDistanceMotionThreshold => parent?.dragStartDistanceMotionThreshold;
@override @override
String toString() { String toString() {
if (parent == null) if (parent == null)
...@@ -326,6 +332,9 @@ class BouncingScrollPhysics extends ScrollPhysics { ...@@ -326,6 +332,9 @@ class BouncingScrollPhysics extends ScrollPhysics {
return existingVelocity.sign * return existingVelocity.sign *
math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0); math.min(0.000816 * math.pow(existingVelocity.abs(), 1.967).toDouble(), 40000.0);
} }
@override
double get dragStartDistanceMotionThreshold => 3.5; // Eyeballed from observation.
} }
/// Scroll physics for environments that prevent the scroll offset from reaching /// Scroll physics for environments that prevent the scroll offset from reaching
......
...@@ -246,6 +246,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc ...@@ -246,6 +246,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
details: details, details: details,
onDragCanceled: onDragCanceled, onDragCanceled: onDragCanceled,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity), carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
); );
beginActivity(new DragScrollActivity(this, drag)); beginActivity(new DragScrollActivity(this, drag));
assert(_currentDrag == null); assert(_currentDrag == null);
......
...@@ -133,4 +133,79 @@ void main() { ...@@ -133,4 +133,79 @@ void main() {
// After a hold longer than 2 frames, previous velocity is lost. // After a hold longer than 2 frames, previous velocity is lost.
expect(getScrollVelocity(tester), 0.0); expect(getScrollVelocity(tester), 0.0);
}); });
testWidgets('Drags creeping unaffected on Android', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.android);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -0.5));
expect(getScrollOffset(tester), 0.5);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10));
expect(getScrollOffset(tester), 1.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 1.5);
});
testWidgets('Drags creeping must break threshold on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -0.5));
expect(getScrollOffset(tester), 0.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 10));
expect(getScrollOffset(tester), 0.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 0.0);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 30));
// Now -2.5 in total.
expect(getScrollOffset(tester), 0.0);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 40));
// Now -3.5, just reached threshold.
expect(getScrollOffset(tester), 0.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 50));
// -0.5 over threshold transferred.
expect(getScrollOffset(tester), 0.5);
});
testWidgets('Big drag over threshold magnitude preserved on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0));
// No offset lost from threshold.
expect(getScrollOffset(tester), 20.0);
});
testWidgets('Small continuing motion preserved on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold.
expect(getScrollOffset(tester), 20.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 20.5);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40));
expect(getScrollOffset(tester), 21.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60));
expect(getScrollOffset(tester), 21.5);
});
testWidgets('Motion stop resets threshold on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold.
expect(getScrollOffset(tester), 20.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 20.5);
await gesture.moveBy(Offset.zero);
// 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));
expect(getScrollOffset(tester), 20.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150));
expect(getScrollOffset(tester), 20.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160));
expect(getScrollOffset(tester), 20.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170));
// New threshold broken.
expect(getScrollOffset(tester), 21.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180));
expect(getScrollOffset(tester), 22.5);
});
} }
...@@ -163,16 +163,16 @@ class TestGesture { ...@@ -163,16 +163,16 @@ class TestGesture {
final TestPointer _pointer; final TestPointer _pointer;
/// Send a move event moving the pointer by the given offset. /// Send a move event moving the pointer by the given offset.
Future<Null> moveBy(Offset offset) { Future<Null> moveBy(Offset offset, { Duration timeStamp: Duration.ZERO }) {
assert(_pointer._isDown); assert(_pointer._isDown);
return moveTo(_pointer.location + offset); return moveTo(_pointer.location + offset, timeStamp: timeStamp);
} }
/// Send a move event moving the pointer to the given location. /// Send a move event moving the pointer to the given location.
Future<Null> moveTo(Offset location) { Future<Null> moveTo(Offset location, { Duration timeStamp: Duration.ZERO }) {
return TestAsyncUtils.guard(() { return TestAsyncUtils.guard(() {
assert(_pointer._isDown); assert(_pointer._isDown);
return _dispatcher(_pointer.move(location), _result); return _dispatcher(_pointer.move(location, timeStamp: timeStamp), _result);
}); });
} }
......
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