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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user