Unverified Commit e1367745 authored by Greg Price's avatar Greg Price Committed by GitHub

Make ClampingScrollSimulation ballistic and more like Android (#120420)

Make ClampingScrollSimulation ballistic and more like Android
parent ecd7518d
......@@ -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 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);
// 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;
}
// 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',
......
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