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 {
@required DragStartDetails details,
this.onDragCanceled,
this.carriedVelocity,
this.motionStartDistanceThreshold,
}) : assert(delegate != null),
assert(details != null),
assert(
motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0,
'motionStartDistanceThreshold must be a positive number or null'
),
_delegate = delegate,
_lastDetails = details,
_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.
ScrollActivityDelegate get delegate => _delegate;
......@@ -245,15 +251,27 @@ class ScrollDragController implements Drag {
/// began.
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;
bool _retainMomentum;
/// Null if already in motion or has no [motionStartDistanceThreshold].
double _offsetSinceLastStop;
/// 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 =
static const Duration momentumRetainStationaryDurationThreshold =
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);
/// Updates the controller's link to the [ScrollActivityDelegate].
......@@ -265,22 +283,67 @@ class ScrollDragController implements Drag {
_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
void update(DragUpdateDetails details) {
assert(details.primaryDelta != null);
_lastDetails = details;
double offset = details.primaryDelta;
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 {
if (offset != 0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
_maybeLoseMomentum(offset, details.sourceTimeStamp);
if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) {
return;
}
if (_reversed) // e.g. an AxisDirection.up scrollable
offset = -offset;
delegate.applyUserOffset(offset);
......
......@@ -207,6 +207,12 @@ class ScrollPhysics {
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
String toString() {
if (parent == null)
......@@ -326,6 +332,9 @@ class BouncingScrollPhysics extends ScrollPhysics {
return existingVelocity.sign *
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
......
......@@ -246,6 +246,7 @@ class ScrollPositionWithSingleContext extends ScrollPosition implements ScrollAc
details: details,
onDragCanceled: onDragCanceled,
carriedVelocity: physics.carriedMomentum(_heldPreviousVelocity),
motionStartDistanceThreshold: physics.dragStartDistanceMotionThreshold,
);
beginActivity(new DragScrollActivity(this, drag));
assert(_currentDrag == null);
......
......@@ -133,4 +133,79 @@ void main() {
// After a hold longer than 2 frames, previous velocity is lost.
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 {
final TestPointer _pointer;
/// 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);
return moveTo(_pointer.location + offset);
return moveTo(_pointer.location + offset, timeStamp: timeStamp);
}
/// 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(() {
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