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 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
......@@ -272,6 +273,10 @@ class ScrollDragController implements Drag {
static const Duration motionStoppedDurationThreshold =
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);
/// Updates the controller's link to the [ScrollActivityDelegate].
......@@ -295,15 +300,17 @@ class ScrollDragController implements Drag {
}
}
/// If a motion start threshold exists, determine whether the threshold is
/// reached to start applying position offset.
/// If a motion start threshold exists, determine whether the threshold needs
/// 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.
bool _breakMotionStartThreshold(double offset, Duration timestamp) {
/// Returns `0.0` when stationary or within threshold. Returns `offset`
/// transparently when already in motion.
double _adjustForScrollStartThreshold(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;
return offset;
}
if (offset == 0.0) {
......@@ -314,19 +321,32 @@ class ScrollDragController implements Drag {
_offsetSinceLastStop = 0.0;
}
// Not moving can't break threshold.
return false;
return 0.0;
} else {
if (_offsetSinceLastStop == null) {
// Already in motion. Allow transparent offset transmission.
return true;
// Already in motion or no threshold behavior configured such as for
// Android. Allow transparent offset transmission.
return offset;
} else {
_offsetSinceLastStop += offset;
if (_offsetSinceLastStop.abs() > motionStartDistanceThreshold) {
// Threshold broken.
_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 {
return false;
return 0.0;
}
}
}
......@@ -337,11 +357,15 @@ class ScrollDragController implements Drag {
assert(details.primaryDelta != null);
_lastDetails = details;
double offset = details.primaryDelta;
if (offset != 0) {
if (offset != 0.0) {
_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);
if (!_breakMotionStartThreshold(offset, details.sourceTimeStamp)) {
offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) {
return;
}
if (_reversed) // e.g. an AxisDirection.up scrollable
......
......@@ -345,8 +345,10 @@ class BouncingScrollPhysics extends ScrollPhysics {
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
double get dragStartDistanceMotionThreshold => 3.5; // Eyeballed from observation.
double get dragStartDistanceMotionThreshold => 3.5;
}
/// Scroll physics for environments that prevent the scroll offset from reaching
......
......@@ -727,7 +727,8 @@ void main() {
expect(controller.selectedItem, 46);
// A tester.fling creates and pumps 50 pointer events.
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.
await tester.pumpAndSettle();
......@@ -737,7 +738,7 @@ void main() {
// Lands on 49.
expect(controller.selectedItem, 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;
});
......
......@@ -46,9 +46,10 @@ void main() {
await pumpTest(tester, TargetPlatform.iOS);
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
expect(getCurrentOffset(), dragOffset);
expect(getCurrentOffset(), moreOrLessEquals(210.71026666666666));
await tester.pump(const Duration(seconds: 5));
final double result2 = getCurrentOffset();
......
......@@ -55,9 +55,10 @@ void main() {
await pumpTest(tester, TargetPlatform.iOS);
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
expect(getScrollOffset(tester), dragOffset);
expect(getScrollOffset(tester), moreOrLessEquals(197.16666666666669));
await tester.pump(const Duration(seconds: 5));
final double result2 = getScrollOffset(tester);
......@@ -72,11 +73,13 @@ void main() {
await tester.pump(const Duration(milliseconds: 10));
expect(getScrollOffset(tester), greaterThan(-200.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)));
expect(await tester.pumpAndSettle(), 1);
expect(getScrollOffset(tester), position);
expect(getScrollOffset(tester), heldPosition);
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(getScrollOffset(tester), 0.0);
});
......@@ -168,44 +171,55 @@ void main() {
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));
await gesture.moveBy(const Offset(0.0, -30.0));
// 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 {
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, -30.0)); // Break threshold.
expect(getScrollOffset(tester), 30.0);
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));
expect(getScrollOffset(tester), 21.0);
expect(getScrollOffset(tester), 31.0);
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 {
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, -30.0)); // Break threshold.
expect(getScrollOffset(tester), 30.0);
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);
// 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);
expect(getScrollOffset(tester), 30.5);
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));
expect(getScrollOffset(tester), 20.5);
expect(getScrollOffset(tester), 30.5);
await gesture.moveBy(const Offset(0.0, -1.0), timeStamp: const Duration(milliseconds: 170));
// 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));
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