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.
This commit is contained in:
parent
1ba99b94f2
commit
133c98a85b
@ -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);
|
||||
});
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user