Unverified Commit 133c98a8 authored by xster's avatar xster Committed by GitHub

Fine-tune iOS's scroll start feel (#16721)

* Fine-tune iOS's scroll start feel

* remove negations in doc

* Our own dart-side gesture arena also contributes to the 'jerk'. Make sure that snap is accounted as well.

* Added more code comments from review.
parent 1ba99b94
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
...@@ -272,6 +273,10 @@ class ScrollDragController implements Drag { ...@@ -272,6 +273,10 @@ class ScrollDragController implements Drag {
static const Duration motionStoppedDurationThreshold = static const Duration motionStoppedDurationThreshold =
const Duration(milliseconds: 50); const Duration(milliseconds: 50);
/// The drag distance past which, a [motionStartDistanceThreshold] breaking
/// drag is considered a deliberate fling.
static const double _kBigThresholdBreakDistance = 24.0;
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].
...@@ -295,15 +300,17 @@ class ScrollDragController implements Drag { ...@@ -295,15 +300,17 @@ class ScrollDragController implements Drag {
} }
} }
/// If a motion start threshold exists, determine whether the threshold is /// If a motion start threshold exists, determine whether the threshold needs
/// reached to start applying position offset. /// to be broken to scroll. Also possibly apply an offset adjustment when
/// threshold is first broken.
/// ///
/// Returns false either way if there's no offset. /// Returns `0.0` when stationary or within threshold. Returns `offset`
bool _breakMotionStartThreshold(double offset, Duration timestamp) { /// transparently when already in motion.
double _adjustForScrollStartThreshold(double offset, Duration timestamp) {
if (timestamp == null) { if (timestamp == null) {
// If we can't track time, we can't apply thresholds. // If we can't track time, we can't apply thresholds.
// May be null for proxied drags like via accessibility. // May be null for proxied drags like via accessibility.
return true; return offset;
} }
if (offset == 0.0) { if (offset == 0.0) {
...@@ -314,19 +321,32 @@ class ScrollDragController implements Drag { ...@@ -314,19 +321,32 @@ class ScrollDragController implements Drag {
_offsetSinceLastStop = 0.0; _offsetSinceLastStop = 0.0;
} }
// Not moving can't break threshold. // Not moving can't break threshold.
return false; return 0.0;
} else { } else {
if (_offsetSinceLastStop == null) { if (_offsetSinceLastStop == null) {
// Already in motion. Allow transparent offset transmission. // Already in motion or no threshold behavior configured such as for
return true; // Android. Allow transparent offset transmission.
return offset;
} else { } else {
_offsetSinceLastStop += offset; _offsetSinceLastStop += offset;
if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) { if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) {
// Threshold broken. // Threshold broken.
_offsetSinceLastStop = null; _offsetSinceLastStop = null;
return true; if (offset.abs() > _kBigThresholdBreakDistance) {
// This is heuristically a very deliberate fling. Leave the motion
// unaffected.
return offset;
} else {
// This is a normal speed threshold break.
return math.min(
// Ease into the motion when the threshold is initially broken
// to avoid a visible jump.
motionStartDistanceThreshold / 3.0,
offset.abs()
) * offset.sign;
}
} else { } else {
return false; return 0.0;
} }
} }
} }
...@@ -337,11 +357,15 @@ class ScrollDragController implements Drag { ...@@ -337,11 +357,15 @@ class ScrollDragController implements Drag {
assert(details.primaryDelta != null); assert(details.primaryDelta != null);
_lastDetails = details; _lastDetails = details;
double offset = details.primaryDelta; double offset = details.primaryDelta;
if (offset != 0) { if (offset != 0.0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp; _lastNonStationaryTimestamp = details.sourceTimeStamp;
} }
// By default, iOS platforms carries momentum and has a start threshold
// (configured in [BouncingScrollPhysics]). The 2 operations below are
// no-ops on Android.
_maybeLoseMomentum(offset, details.sourceTimeStamp); _maybeLoseMomentum(offset, details.sourceTimeStamp);
if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) { offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) {
return; return;
} }
if (_reversed) // e.g. an AxisDirection.up scrollable if (_reversed) // e.g. an AxisDirection.up scrollable
......
...@@ -345,8 +345,10 @@ class BouncingScrollPhysics extends ScrollPhysics { ...@@ -345,8 +345,10 @@ class BouncingScrollPhysics extends ScrollPhysics {
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);
} }
// Eyeballed from observation to counter the effect of an unintended scroll
// from the natural motion of lifting the finger after a scroll.
@override @override
double get dragStartDistanceMotionThreshold => 3.5; // Eyeballed from observation. double get dragStartDistanceMotionThreshold => 3.5;
} }
/// Scroll physics for environments that prevent the scroll offset from reaching /// Scroll physics for environments that prevent the scroll offset from reaching
......
...@@ -727,7 +727,8 @@ void main() { ...@@ -727,7 +727,8 @@ void main() {
expect(controller.selectedItem, 46); expect(controller.selectedItem, 46);
// A tester.fling creates and pumps 50 pointer events. // A tester.fling creates and pumps 50 pointer events.
expect(scrolledPositions.length, 50); expect(scrolledPositions.length, 50);
expect(scrolledPositions.last, moreOrLessEquals(40 * 100.0 + 567.0, epsilon: 0.2)); // iOS flings ease-in initially.
expect(scrolledPositions.last, moreOrLessEquals(40 * 100.0 + 556.826666666673, epsilon: 0.2));
// Let the spring back simulation finish. // Let the spring back simulation finish.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
...@@ -737,7 +738,7 @@ void main() { ...@@ -737,7 +738,7 @@ void main() {
// Lands on 49. // Lands on 49.
expect(controller.selectedItem, 49); expect(controller.selectedItem, 49);
// More importantly, lands tightly on 49. // More importantly, lands tightly on 49.
expect(scrolledPositions.last, moreOrLessEquals(49 * 100.0, epsilon: 0.2)); expect(scrolledPositions.last, moreOrLessEquals(49 * 100.0, epsilon: 0.3));
debugDefaultTargetPlatformOverride = null; debugDefaultTargetPlatformOverride = null;
}); });
......
...@@ -46,9 +46,10 @@ void main() { ...@@ -46,9 +46,10 @@ void main() {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(ListView), const Offset(0.0, -dragOffset), 1000.0); await tester.fling(find.byType(ListView), const Offset(0.0, -dragOffset), 1000.0);
expect(getCurrentOffset(), dragOffset); // Scroll starts ease into the scroll on iOS.
expect(getCurrentOffset(), moreOrLessEquals(210.71026666666666));
await tester.pump(); // trigger fling await tester.pump(); // trigger fling
expect(getCurrentOffset(), dragOffset); expect(getCurrentOffset(), moreOrLessEquals(210.71026666666666));
await tester.pump(const Duration(seconds: 5)); await tester.pump(const Duration(seconds: 5));
final double result2 = getCurrentOffset(); final double result2 = getCurrentOffset();
......
...@@ -55,9 +55,10 @@ void main() { ...@@ -55,9 +55,10 @@ void main() {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0); await tester.fling(find.byType(Viewport), const Offset(0.0, -dragOffset), 1000.0);
expect(getScrollOffset(tester), dragOffset); // Scroll starts ease into the scroll on iOS.
expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669));
await tester.pump(); // trigger fling await tester.pump(); // trigger fling
expect(getScrollOffset(tester), dragOffset); expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669));
await tester.pump(const Duration(seconds: 5)); await tester.pump(const Duration(seconds: 5));
final double result2 = getScrollOffset(tester); final double result2 = getScrollOffset(tester);
...@@ -72,11 +73,13 @@ void main() { ...@@ -72,11 +73,13 @@ void main() {
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(getScrollOffset(tester), greaterThan(-200.0)); expect(getScrollOffset(tester), greaterThan(-200.0));
expect(getScrollOffset(tester), lessThan(0.0)); expect(getScrollOffset(tester), lessThan(0.0));
final double position = getScrollOffset(tester); final double heldPosition = getScrollOffset(tester);
// Hold and let go while in overscroll.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
expect(await tester.pumpAndSettle(), 1); expect(await tester.pumpAndSettle(), 1);
expect(getScrollOffset(tester), position); expect(getScrollOffset(tester), heldPosition);
await gesture.up(); await gesture.up();
// Once the hold is let go, it should still snap back to origin.
expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2); expect(await tester.pumpAndSettle(const Duration(minutes: 1)), 2);
expect(getScrollOffset(tester), 0.0); expect(getScrollOffset(tester), 0.0);
}); });
...@@ -168,44 +171,55 @@ void main() { ...@@ -168,44 +171,55 @@ void main() {
testWidgets('Big drag over threshold magnitude preserved on iOS', (WidgetTester tester) async { testWidgets('Big drag over threshold magnitude preserved on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0)); await gesture.moveBy(const Offset(0.0, -30.0));
// No offset lost from threshold. // No offset lost from threshold.
expect(getScrollOffset(tester), 20.0); expect(getScrollOffset(tester), 30.0);
});
testWidgets('Slow threshold breaks are attenuated on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
// This is a typical 'hesitant' iOS scroll start.
await gesture.moveBy(const Offset(0.0, -10.0));
expect(getScrollOffset(tester), moreOrLessEquals(1.1666666666666667));
await gesture.moveBy(const Offset(0.0, -10.0), timeStamp: const Duration(milliseconds: 20));
// Subsequent motions unaffected.
expect(getScrollOffset(tester), moreOrLessEquals(11.16666666666666673));
}); });
testWidgets('Small continuing motion preserved on iOS', (WidgetTester tester) async { testWidgets('Small continuing motion preserved on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold. await gesture.moveBy(const Offset(0.0, -30.0)); // Break threshold.
expect(getScrollOffset(tester), 20.0); expect(getScrollOffset(tester), 30.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 20.5); expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40)); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 40));
expect(getScrollOffset(tester), 21.0); expect(getScrollOffset(tester), 31.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60)); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 60));
expect(getScrollOffset(tester), 21.5); expect(getScrollOffset(tester), 31.5);
}); });
testWidgets('Motion stop resets threshold on iOS', (WidgetTester tester) async { testWidgets('Motion stop resets threshold on iOS', (WidgetTester tester) async {
await pumpTest(tester, TargetPlatform.iOS); await pumpTest(tester, TargetPlatform.iOS);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Viewport)));
await gesture.moveBy(const Offset(0.0, -20.0)); // Break threshold. await gesture.moveBy(const Offset(0.0, -30.0)); // Break threshold.
expect(getScrollOffset(tester), 20.0); expect(getScrollOffset(tester), 30.0);
await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20)); await gesture.moveBy(const Offset(0.0, -0.5), timeStamp: const Duration(milliseconds: 20));
expect(getScrollOffset(tester), 20.5); expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(Offset.zero); await gesture.moveBy(Offset.zero);
// Stationary too long, threshold reset. // Stationary too long, threshold reset.
await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 120)); await gesture.moveBy(Offset.zero, timeStamp: const Duration(milliseconds: 120));
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 140)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 140));
expect(getScrollOffset(tester), 20.5); expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 150));
expect(getScrollOffset(tester), 20.5); expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 160));
expect(getScrollOffset(tester), 20.5); expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170));
// New threshold broken. // New threshold broken.
expect(getScrollOffset(tester), 21.5); expect(getScrollOffset(tester), 31.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180)); await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 180));
expect(getScrollOffset(tester), 22.5); expect(getScrollOffset(tester), 32.5);
}); });
} }
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