diff --git a/packages/flutter/lib/src/widgets/scroll_simulation.dart b/packages/flutter/lib/src/widgets/scroll_simulation.dart index 3dab8a84bfd..61cc18bc769 100644 --- a/packages/flutter/lib/src/widgets/scroll_simulation.dart +++ b/packages/flutter/lib/src/widgets/scroll_simulation.dart @@ -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 diff --git a/packages/flutter/test/widgets/scroll_simulation_test.dart b/packages/flutter/test/widgets/scroll_simulation_test.dart index 69958f91e31..c3432c02994 100644 --- a/packages/flutter/test/widgets/scroll_simulation_test.dart +++ b/packages/flutter/test/widgets/scroll_simulation_test.dart @@ -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 xsRestarted = []; + final List xsUndisturbed = []; + final List dxsRestarted = []; + final List dxsUndisturbed = []; + 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); + }); } diff --git a/packages/flutter/test/widgets/scrollable_fling_test.dart b/packages/flutter/test/widgets/scrollable_fling_test.dart index 095df2a1350..aaedfd47c89 100644 --- a/packages/flutter/test/widgets/scrollable_fling_test.dart +++ b/packages/flutter/test/widgets/scrollable_fling_test.dart @@ -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(['tap 21'])); await tester.tap(find.byType(Scrollable)); await tester.pump(const Duration(milliseconds: 50)); - expect(log, equals(['tap 21', 'tap 48'])); + expect(log, equals(['tap 21', 'tap 49'])); }); } diff --git a/packages/flutter/test/widgets/scrollable_semantics_test.dart b/packages/flutter/test/widgets/scrollable_semantics_test.dart index 1c8f7ea44dd..5307fc7a6d7 100644 --- a/packages/flutter/test/widgets/scrollable_semantics_test.dart +++ b/packages/flutter/test/widgets/scrollable_semantics_test.dart @@ -231,7 +231,7 @@ void main() { expect(semantics, includesNodeWith( scrollExtentMin: 0.0, - scrollPosition: 380.2, + scrollPosition: 394.3, scrollExtentMax: 520.0, actions: [ 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.scrollUp, @@ -292,7 +292,7 @@ void main() { expect(semantics, includesNodeWith( scrollExtentMin: 0.0, - scrollPosition: 760.4, + scrollPosition: 788.6, scrollExtentMax: double.infinity, actions: [ SemanticsAction.scrollUp, diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 9229df2a5b9..8415b7fa378 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -1069,8 +1069,8 @@ void main() { expect(find.byKey(const ValueKey('Box 0')), findsNothing); expect(find.byKey(const ValueKey('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('Box 0')), findsNothing); expect(find.byKey(const ValueKey('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('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 { diff --git a/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart b/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart index 5306450e898..7d22fc79413 100644 --- a/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart +++ b/packages/flutter/test/widgets/slivers_appbar_floating_pinned_test.dart @@ -173,7 +173,7 @@ void main() { TestSemantics( actions: [SemanticsAction.scrollUp, SemanticsAction.scrollDown], flags: [SemanticsFlag.hasImplicitScrolling], - scrollIndex: 10, + scrollIndex: 11, children: [ TestSemantics( label: 'Tile 7', @@ -193,6 +193,7 @@ void main() { TestSemantics( label: 'Tile 10', textDirection: TextDirection.ltr, + flags: [SemanticsFlag.isHidden], ), TestSemantics( label: 'Tile 11',