Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make ClampingScrollSimulation ballistic and more like Android #120420

Merged
merged 6 commits into from Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
133 changes: 82 additions & 51 deletions packages/flutter/lib/src/widgets/scroll_simulation.dart
Expand Up @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aligns. Nice. :)

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should take the form of a See also and a breadcrumb [] like in other docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see this is private, // v ///. You can disregard, unless this would be helpful as a public comment.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. Yeah, this is a reference to the corresponding Android source (like some other private comments in this class), so I think it's useful only for someone actually reading the implementation.

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
Expand Down
132 changes: 132 additions & 0 deletions packages/flutter/test/widgets/scroll_simulation_test.dart
Expand Up @@ -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';

Expand All @@ -23,4 +25,134 @@ void main() {
checkInitialConditions(75.0, 614.2093);
checkInitialConditions(5469.0, 182.114534);
});

test('ClampingScrollSimulation only decelerates, never speeds up', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test that the velocity eventually goes to zero?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, will do.

I think that follows as a consequence of being ballistic/restartable: if the simulation still has significant velocity in the last moments before the end (i.e., if the limit as time goes to the ending time isn't zero), then restarting it at that point would mean it goes on for longer than the few moments that were otherwise remaining.

But it would still be sensible to have a test for it directly.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

// 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);
});
}
6 changes: 3 additions & 3 deletions packages/flutter/test/widgets/scrollable_fling_test.dart
Expand Up @@ -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);
Expand Down Expand Up @@ -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']));
});
}
6 changes: 3 additions & 3 deletions packages/flutter/test/widgets/scrollable_semantics_test.dart
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down