Make ClampingScrollSimulation ballistic and more like Android (#120420)
Make ClampingScrollSimulation ballistic and more like Android
This commit is contained in:
parent
ecd7518df5
commit
e13677450c
@ -123,98 +123,129 @@ class BouncingScrollSimulation extends Simulation {
|
||||
}
|
||||
}
|
||||
|
||||
/// An implementation of scroll physics that matches Android.
|
||||
/// An implementation of scroll physics that aligns with Android.
|
||||
///
|
||||
/// For any value of [velocity], this travels the same total distance as the
|
||||
/// Android scroll physics.
|
||||
///
|
||||
/// This scroll physics has been adjusted relative to Android's in order to make
|
||||
/// it ballistic, meaning that the deceleration at any moment is a function only
|
||||
/// of the current velocity [dx] and does not depend on how long ago the
|
||||
/// simulation was started. (This is required by Flutter's scrolling protocol,
|
||||
/// where [ScrollActivityDelegate.goBallistic] may restart a scroll activity
|
||||
/// using only its current velocity and the scroll position's own state.)
|
||||
/// Compared to this scroll physics, Android's moves faster at the very
|
||||
/// beginning, then slower, and it ends at the same place but a little later.
|
||||
///
|
||||
/// Times are measured in seconds, and positions in logical pixels.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [BouncingScrollSimulation], which implements iOS scroll physics.
|
||||
//
|
||||
// This class is based on Scroller.java from Android:
|
||||
// https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget
|
||||
// This class is based on OverScroller.java from Android:
|
||||
// https://android.googlesource.com/platform/frameworks/base/+/android-13.0.0_r24/core/java/android/widget/OverScroller.java#738
|
||||
// and in particular class SplineOverScroller (at the end of the file), starting
|
||||
// at method "fling". (A very similar algorithm is in Scroller.java in the same
|
||||
// directory, but OverScroller is what's used by RecyclerView.)
|
||||
//
|
||||
// The "See..." comments below refer to Scroller methods and values. Some
|
||||
// simplifications have been made.
|
||||
// In the Android implementation, times are in milliseconds, positions are in
|
||||
// physical pixels, but velocity is in physical pixels per whole second.
|
||||
//
|
||||
// The "See..." comments below refer to SplineOverScroller methods and values.
|
||||
class ClampingScrollSimulation extends Simulation {
|
||||
/// Creates a scroll physics simulation that matches Android scrolling.
|
||||
/// Creates a scroll physics simulation that aligns with Android scrolling.
|
||||
ClampingScrollSimulation({
|
||||
required this.position,
|
||||
required this.velocity,
|
||||
this.friction = 0.015,
|
||||
super.tolerance,
|
||||
}) : assert(_flingVelocityPenetration(0.0) == _initialVelocityPenetration) {
|
||||
_duration = _flingDuration(velocity);
|
||||
_distance = (velocity * _duration / _initialVelocityPenetration).abs();
|
||||
}) {
|
||||
_duration = _flingDuration();
|
||||
_distance = _flingDistance();
|
||||
}
|
||||
|
||||
/// The position of the particle at the beginning of the simulation.
|
||||
/// The position of the particle at the beginning of the simulation, in
|
||||
/// logical pixels.
|
||||
final double position;
|
||||
|
||||
/// The velocity at which the particle is traveling at the beginning of the
|
||||
/// simulation.
|
||||
/// simulation, in logical pixels per second.
|
||||
final double velocity;
|
||||
|
||||
/// The amount of friction the particle experiences as it travels.
|
||||
///
|
||||
/// The more friction the particle experiences, the sooner it stops.
|
||||
/// The more friction the particle experiences, the sooner it stops and the
|
||||
/// less far it travels.
|
||||
///
|
||||
/// The default value causes the particle to travel the same total distance
|
||||
/// as in the Android scroll physics.
|
||||
// See mFlingFriction.
|
||||
final double friction;
|
||||
|
||||
/// The total time the simulation will run, in seconds.
|
||||
late double _duration;
|
||||
|
||||
/// The total, signed, distance the simulation will travel, in logical pixels.
|
||||
late double _distance;
|
||||
|
||||
// See DECELERATION_RATE.
|
||||
static final double _kDecelerationRate = math.log(0.78) / math.log(0.9);
|
||||
|
||||
// See computeDeceleration().
|
||||
static double _decelerationForFriction(double friction) {
|
||||
return friction * 61774.04968;
|
||||
// See INFLEXION.
|
||||
static const double _kInflexion = 0.35;
|
||||
|
||||
// See mPhysicalCoeff. This has a value of 0.84 times Earth gravity,
|
||||
// expressed in units of logical pixels per second^2.
|
||||
static const double _physicalCoeff =
|
||||
9.80665 // g, in meters per second^2
|
||||
* 39.37 // 1 meter / 1 inch
|
||||
* 160.0 // 1 inch / 1 logical pixel
|
||||
* 0.84; // "look and feel tuning"
|
||||
|
||||
// See getSplineFlingDuration().
|
||||
double _flingDuration() {
|
||||
// See getSplineDeceleration(). That function's value is
|
||||
// math.log(velocity.abs() / referenceVelocity).
|
||||
final double referenceVelocity = friction * _physicalCoeff / _kInflexion;
|
||||
|
||||
// This is the value getSplineFlingDuration() would return, but in seconds.
|
||||
final double androidDuration =
|
||||
math.pow(velocity.abs() / referenceVelocity,
|
||||
1 / (_kDecelerationRate - 1.0)) as double;
|
||||
|
||||
// We finish a bit sooner than Android, in order to travel the
|
||||
// same total distance.
|
||||
return _kDecelerationRate * _kInflexion * androidDuration;
|
||||
}
|
||||
|
||||
// See getSplineFlingDuration(). Returns a value in seconds.
|
||||
double _flingDuration(double velocity) {
|
||||
// See mPhysicalCoeff
|
||||
final double scaledFriction = friction * _decelerationForFriction(0.84);
|
||||
|
||||
// See getSplineDeceleration().
|
||||
final double deceleration = math.log(0.35 * velocity.abs() / scaledFriction);
|
||||
|
||||
return math.exp(deceleration / (_kDecelerationRate - 1.0));
|
||||
}
|
||||
|
||||
// Based on a cubic curve fit to the Scroller.computeScrollOffset() values
|
||||
// produced for an initial velocity of 4000. The value of Scroller.getDuration()
|
||||
// and Scroller.getFinalY() were 686ms and 961 pixels respectively.
|
||||
//
|
||||
// Algebra courtesy of Wolfram Alpha.
|
||||
//
|
||||
// f(x) = scrollOffset, x is time in milliseconds
|
||||
// f(x) = 3.60882×10^-6 x^3 - 0.00668009 x^2 + 4.29427 x - 3.15307
|
||||
// f(x) = 3.60882×10^-6 x^3 - 0.00668009 x^2 + 4.29427 x, so f(0) is 0
|
||||
// f(686ms) = 961 pixels
|
||||
// Scale to f(0 <= t <= 1.0), x = t * 686
|
||||
// f(t) = 1165.03 t^3 - 3143.62 t^2 + 2945.87 t
|
||||
// Scale f(t) so that 0.0 <= f(t) <= 1.0
|
||||
// f(t) = (1165.03 t^3 - 3143.62 t^2 + 2945.87 t) / 961.0
|
||||
// = 1.2 t^3 - 3.27 t^2 + 3.065 t
|
||||
static const double _initialVelocityPenetration = 3.065;
|
||||
static double _flingDistancePenetration(double t) {
|
||||
return (1.2 * t * t * t) - (3.27 * t * t) + (_initialVelocityPenetration * t);
|
||||
}
|
||||
|
||||
// The derivative of the _flingDistancePenetration() function.
|
||||
static double _flingVelocityPenetration(double t) {
|
||||
return (3.6 * t * t) - (6.54 * t) + _initialVelocityPenetration;
|
||||
// See getSplineFlingDistance(). This returns the same value but with the
|
||||
// sign of [velocity], and in logical pixels.
|
||||
double _flingDistance() {
|
||||
final double distance = velocity * _duration / _kDecelerationRate;
|
||||
assert(() {
|
||||
// This is the more complicated calculation that getSplineFlingDistance()
|
||||
// actually performs, which boils down to the much simpler formula above.
|
||||
final double referenceVelocity = friction * _physicalCoeff / _kInflexion;
|
||||
final double logVelocity = math.log(velocity.abs() / referenceVelocity);
|
||||
final double distanceAgain =
|
||||
friction * _physicalCoeff
|
||||
* math.exp(logVelocity * _kDecelerationRate / (_kDecelerationRate - 1.0));
|
||||
return (distance.abs() - distanceAgain).abs() < tolerance.distance;
|
||||
}());
|
||||
return distance;
|
||||
}
|
||||
|
||||
@override
|
||||
double x(double time) {
|
||||
final double t = clampDouble(time / _duration, 0.0, 1.0);
|
||||
return position + _distance * _flingDistancePenetration(t) * velocity.sign;
|
||||
return position + _distance * (1.0 - math.pow(1.0 - t, _kDecelerationRate));
|
||||
}
|
||||
|
||||
@override
|
||||
double dx(double time) {
|
||||
final double t = clampDouble(time / _duration, 0.0, 1.0);
|
||||
return _distance * _flingVelocityPenetration(t) * velocity.sign / _duration;
|
||||
return velocity * math.pow(1.0 - t, _kDecelerationRate - 1.0);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -2,6 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@ -23,4 +25,134 @@ void main() {
|
||||
checkInitialConditions(75.0, 614.2093);
|
||||
checkInitialConditions(5469.0, 182.114534);
|
||||
});
|
||||
|
||||
test('ClampingScrollSimulation only decelerates, never speeds up', () {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/113424
|
||||
final ClampingScrollSimulation simulation =
|
||||
ClampingScrollSimulation(position: 0, velocity: 8000.0);
|
||||
double time = 0.0;
|
||||
double velocity = simulation.dx(time);
|
||||
while (!simulation.isDone(time)) {
|
||||
expect(time, lessThan(3.0));
|
||||
time += 1 / 60;
|
||||
final double nextVelocity = simulation.dx(time);
|
||||
expect(nextVelocity, lessThanOrEqualTo(velocity));
|
||||
velocity = nextVelocity;
|
||||
}
|
||||
});
|
||||
|
||||
test('ClampingScrollSimulation reaches a smooth stop: velocity is continuous and goes to zero', () {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/113424
|
||||
const double initialVelocity = 8000.0;
|
||||
const double maxDeceleration = 5130.0; // -acceleration(initialVelocity), from formula below
|
||||
final ClampingScrollSimulation simulation =
|
||||
ClampingScrollSimulation(position: 0, velocity: initialVelocity);
|
||||
|
||||
double time = 0.0;
|
||||
double velocity = simulation.dx(time);
|
||||
const double delta = 1 / 60;
|
||||
do {
|
||||
expect(time, lessThan(3.0));
|
||||
time += delta;
|
||||
final double nextVelocity = simulation.dx(time);
|
||||
expect((nextVelocity - velocity).abs(), lessThan(delta * maxDeceleration));
|
||||
velocity = nextVelocity;
|
||||
} while (!simulation.isDone(time));
|
||||
expect(velocity, moreOrLessEquals(0.0));
|
||||
});
|
||||
|
||||
test('ClampingScrollSimulation is ballistic', () {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/120338
|
||||
const double delta = 1 / 90;
|
||||
final ClampingScrollSimulation undisturbed =
|
||||
ClampingScrollSimulation(position: 0, velocity: 8000.0);
|
||||
|
||||
double time = 0.0;
|
||||
ClampingScrollSimulation restarted = undisturbed;
|
||||
final List<double> xsRestarted = <double>[];
|
||||
final List<double> xsUndisturbed = <double>[];
|
||||
final List<double> dxsRestarted = <double>[];
|
||||
final List<double> dxsUndisturbed = <double>[];
|
||||
do {
|
||||
expect(time, lessThan(4.0));
|
||||
time += delta;
|
||||
restarted = ClampingScrollSimulation(
|
||||
position: restarted.x(delta), velocity: restarted.dx(delta));
|
||||
xsRestarted.add(restarted.x(0));
|
||||
xsUndisturbed.add(undisturbed.x(time));
|
||||
dxsRestarted.add(restarted.dx(0));
|
||||
dxsUndisturbed.add(undisturbed.dx(time));
|
||||
} while (!restarted.isDone(0) || !undisturbed.isDone(time));
|
||||
|
||||
// Compare the headline number first: the total distances traveled.
|
||||
// This way, if the test fails, it shows the big final difference
|
||||
// instead of the tiny difference that's in the very first frame.
|
||||
expect(xsRestarted.last, moreOrLessEquals(xsUndisturbed.last));
|
||||
|
||||
// The whole trajectories along the way should match too.
|
||||
for (int i = 0; i < xsRestarted.length; i++) {
|
||||
expect(xsRestarted[i], moreOrLessEquals(xsUndisturbed[i]));
|
||||
expect(dxsRestarted[i], moreOrLessEquals(dxsUndisturbed[i]));
|
||||
}
|
||||
});
|
||||
|
||||
test('ClampingScrollSimulation satisfies a physical acceleration formula', () {
|
||||
// Different regression test for https://github.com/flutter/flutter/issues/120338
|
||||
//
|
||||
// This one provides a formula for the particle's acceleration as a function
|
||||
// of its velocity, and checks that it behaves according to that formula.
|
||||
// The point isn't that it's this specific formula, but just that there's
|
||||
// some formula which depends only on velocity, not time, so that the
|
||||
// physical metaphor makes sense.
|
||||
|
||||
// Copied from the implementation.
|
||||
final double kDecelerationRate = math.log(0.78) / math.log(0.9);
|
||||
|
||||
// Same as the referenceVelocity in _flingDuration.
|
||||
const double referenceVelocity = .015 * 9.80665 * 39.37 * 160.0 * 0.84 / 0.35;
|
||||
|
||||
// The value of _duration when velocity == referenceVelocity.
|
||||
final double referenceDuration = kDecelerationRate * 0.35;
|
||||
|
||||
// The rate of deceleration when dx(time) == referenceVelocity.
|
||||
final double referenceDeceleration = (kDecelerationRate - 1) * referenceVelocity / referenceDuration;
|
||||
|
||||
double acceleration(double velocity) {
|
||||
return - velocity.sign
|
||||
* referenceDeceleration *
|
||||
math.pow(velocity.abs() / referenceVelocity,
|
||||
(kDecelerationRate - 2) / (kDecelerationRate - 1));
|
||||
}
|
||||
|
||||
double jerk(double velocity) {
|
||||
return referenceVelocity / referenceDuration / referenceDuration
|
||||
* (kDecelerationRate - 1) * (kDecelerationRate - 2)
|
||||
* math.pow(velocity.abs() / referenceVelocity,
|
||||
(kDecelerationRate - 3) / (kDecelerationRate - 1));
|
||||
}
|
||||
|
||||
void checkAcceleration(double position, double velocity) {
|
||||
final ClampingScrollSimulation simulation =
|
||||
ClampingScrollSimulation(position: position, velocity: velocity);
|
||||
double time = 0.0;
|
||||
const double delta = 1/60;
|
||||
for (; time < 2.0; time += delta) {
|
||||
final double difference = simulation.dx(time + delta) - simulation.dx(time);
|
||||
final double predictedDifference = delta * acceleration(simulation.dx(time + delta/2));
|
||||
final double maxThirdDerivative = jerk(simulation.dx(time + delta));
|
||||
expect((difference - predictedDifference).abs(),
|
||||
lessThan(maxThirdDerivative * math.pow(delta, 2)/2));
|
||||
}
|
||||
}
|
||||
|
||||
checkAcceleration(51.0, 2866.91537);
|
||||
checkAcceleration(584.0, 2617.294734);
|
||||
checkAcceleration(345.0, 1982.785934);
|
||||
checkAcceleration(0.0, 1831.366634);
|
||||
checkAcceleration(-156.2, 1541.57665);
|
||||
checkAcceleration(4.0, 1139.250439);
|
||||
checkAcceleration(4534.0, 1073.553798);
|
||||
checkAcceleration(75.0, 614.2093);
|
||||
checkAcceleration(5469.0, 182.114534);
|
||||
});
|
||||
}
|
||||
|
@ -47,8 +47,8 @@ void main() {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/83632
|
||||
// Before changing these values, ensure the fling results in a distance that
|
||||
// makes sense. See issue for more context.
|
||||
expect(androidResult, greaterThan(394.0));
|
||||
expect(androidResult, lessThan(395.0));
|
||||
expect(androidResult, greaterThan(408.0));
|
||||
expect(androidResult, lessThan(409.0));
|
||||
|
||||
await pumpTest(tester, TargetPlatform.linux);
|
||||
await tester.fling(find.byType(ListView), const Offset(0.0, -dragOffset), 1000.0);
|
||||
@ -153,6 +153,6 @@ void main() {
|
||||
expect(log, equals(<String>['tap 21']));
|
||||
await tester.tap(find.byType(Scrollable));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(log, equals(<String>['tap 21', 'tap 48']));
|
||||
expect(log, equals(<String>['tap 21', 'tap 49']));
|
||||
});
|
||||
}
|
||||
|
@ -231,7 +231,7 @@ void main() {
|
||||
|
||||
expect(semantics, includesNodeWith(
|
||||
scrollExtentMin: 0.0,
|
||||
scrollPosition: 380.2,
|
||||
scrollPosition: 394.3,
|
||||
scrollExtentMax: 520.0,
|
||||
actions: <SemanticsAction>[
|
||||
SemanticsAction.scrollUp,
|
||||
@ -280,7 +280,7 @@ void main() {
|
||||
|
||||
expect(semantics, includesNodeWith(
|
||||
scrollExtentMin: 0.0,
|
||||
scrollPosition: 380.2,
|
||||
scrollPosition: 394.3,
|
||||
scrollExtentMax: double.infinity,
|
||||
actions: <SemanticsAction>[
|
||||
SemanticsAction.scrollUp,
|
||||
@ -292,7 +292,7 @@ void main() {
|
||||
|
||||
expect(semantics, includesNodeWith(
|
||||
scrollExtentMin: 0.0,
|
||||
scrollPosition: 760.4,
|
||||
scrollPosition: 788.6,
|
||||
scrollExtentMax: double.infinity,
|
||||
actions: <SemanticsAction>[
|
||||
SemanticsAction.scrollUp,
|
||||
|
@ -1069,8 +1069,8 @@ void main() {
|
||||
expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing);
|
||||
expect(find.byKey(const ValueKey<String>('Box 52')), findsOneWidget);
|
||||
|
||||
expect(expensiveWidgets, 38);
|
||||
expect(cheapWidgets, 20);
|
||||
expect(expensiveWidgets, 40);
|
||||
expect(cheapWidgets, 21);
|
||||
});
|
||||
|
||||
testWidgets('Can recommendDeferredLoadingForContext - override heuristic', (WidgetTester tester) async {
|
||||
@ -1112,9 +1112,9 @@ void main() {
|
||||
expect(find.byKey(const ValueKey<String>('Box 0')), findsNothing);
|
||||
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget);
|
||||
|
||||
expect(expensiveWidgets, 18);
|
||||
expect(cheapWidgets, 40);
|
||||
expect(physics.count, 40 + 18);
|
||||
expect(expensiveWidgets, 17);
|
||||
expect(cheapWidgets, 44);
|
||||
expect(physics.count, 44 + 17);
|
||||
});
|
||||
|
||||
testWidgets('Can recommendDeferredLoadingForContext - override heuristic and always return true', (WidgetTester tester) async {
|
||||
@ -1155,7 +1155,7 @@ void main() {
|
||||
expect(find.byKey(const ValueKey<String>('Cheap box 52')), findsOneWidget);
|
||||
|
||||
expect(expensiveWidgets, 0);
|
||||
expect(cheapWidgets, 58);
|
||||
expect(cheapWidgets, 61);
|
||||
});
|
||||
|
||||
testWidgets('ensureVisible does not move PageViews', (WidgetTester tester) async {
|
||||
@ -1641,9 +1641,9 @@ void main() {
|
||||
await tester.sendEventToBinding(testPointer.hover(tester.getCenter(find.byType(Scrollable))));
|
||||
await tester.sendEventToBinding(testPointer.scrollInertiaCancel()); // Cancel partway through.
|
||||
await tester.pump();
|
||||
expect(getScrollOffset(tester), closeTo(333.2944, 0.0001));
|
||||
expect(getScrollOffset(tester), closeTo(344.0642, 0.0001));
|
||||
await tester.pump(const Duration(milliseconds: 4800));
|
||||
expect(getScrollOffset(tester), closeTo(333.2944, 0.0001));
|
||||
expect(getScrollOffset(tester), closeTo(344.0642, 0.0001));
|
||||
});
|
||||
|
||||
testWidgets('Swapping viewports in a scrollable does not crash', (WidgetTester tester) async {
|
||||
|
@ -173,7 +173,7 @@ void main() {
|
||||
TestSemantics(
|
||||
actions: <SemanticsAction>[SemanticsAction.scrollUp, SemanticsAction.scrollDown],
|
||||
flags: <SemanticsFlag>[SemanticsFlag.hasImplicitScrolling],
|
||||
scrollIndex: 10,
|
||||
scrollIndex: 11,
|
||||
children: <TestSemantics>[
|
||||
TestSemantics(
|
||||
label: 'Tile 7',
|
||||
@ -193,6 +193,7 @@ void main() {
|
||||
TestSemantics(
|
||||
label: 'Tile 10',
|
||||
textDirection: TextDirection.ltr,
|
||||
flags: <SemanticsFlag>[SemanticsFlag.isHidden],
|
||||
),
|
||||
TestSemantics(
|
||||
label: 'Tile 11',
|
||||
|
Loading…
x
Reference in New Issue
Block a user