Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions packages/flutter/lib/src/widgets/scroll_physics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,47 @@ class BouncingScrollPhysics extends ScrollPhysics {
/// Used to determine parameters for friction simulations.
final ScrollDecelerationRate decelerationRate;

// Approximation of iOS native rubber band decay rate.
static const double _rubberBandHalfLifeSeconds = 0.07;

// Decay constant (lambda) for rubber band spring simulation.
static final double _rubberBandLambda = math.log(2) / _rubberBandHalfLifeSeconds;

/// Spring used to animate overscroll bounce from a stationary release in iOS
/// native style.
///
/// Used in [createBallisticSimulation] depending on the conditions.
///
/// Research indicates that iOS employs a distinct decay function when a
/// scrollable area is released in an overscroll and stationary state (zero
/// initial velocity), conforming to an exponential decay model.
//
// ## Mathematical derivation
//
// A standard spring-damper system follows the second-order differential equation:
// m*x'' + c*x' + k*x = 0
//
// To force this second-order system to behave like a first-order exponential
// decay x(t) = C * e^(-lambda * t), we configure it as an overdamped spring
// with two explicitly defined roots (r1 and r2) for its characteristic
// equation:
//
// * r1 = -lambda (the primary root driving the visible exponential decay)
// * r2 = -100000 * lambda (an extremely large negative root)
//
// Because r2 is massive and negative, its corresponding term in the exact
// mathematical solution (C2 * e^(r2 * t)) decays to zero almost
// instantaneously. The system movement becomes dominated by r1.
//
// Using Vieta's formulas for the characteristic equation r^2 + (c/m)r + (k/m) = 0:
// * r1 + r2 = -c/m => damping (c) = -(r1 + r2) * m
// * r1 * r2 = k/m => stiffness (k) = (r1 * r2) * m
static final SpringDescription rubberBandSpring = SpringDescription(
mass: 1.0,
stiffness: 1e5 * _rubberBandLambda * _rubberBandLambda,
damping: (1e5 + 1) * _rubberBandLambda,
);

@override
BouncingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return BouncingScrollPhysics(parent: buildParent(ancestor), decelerationRate: decelerationRate);
Expand Down Expand Up @@ -756,9 +797,12 @@ class BouncingScrollPhysics extends ScrollPhysics {
@override
Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) {
final Tolerance tolerance = toleranceFor(position);
if (velocity.abs() >= tolerance.velocity || position.outOfRange) {
final bool isStationary = velocity.abs() < tolerance.velocity;
final bool isRubberBand = isStationary && position.outOfRange;

if (!isStationary || position.outOfRange) {
return BouncingScrollSimulation(
spring: spring,
spring: isRubberBand ? rubberBandSpring : spring,
position: position.pixels,
velocity: velocity,
leadingExtent: position.minScrollExtent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,9 @@ void main() {
await drag2.up();

// verify there's a ballistic animation from overscroll
expect(await tester.pumpAndSettle(), 9);
// With the introduction of the new `rubberBandSpring` (exponential decay model),
// the overscroll settle animation converges faster and takes 8 frames instead of 9.
expect(await tester.pumpAndSettle(), 8);
});
}

Expand Down
31 changes: 31 additions & 0 deletions packages/flutter/test/widgets/scroll_physics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -364,4 +364,35 @@ FlutterError
);
await tester.fling(find.text('Index 2'), const Offset(0.0, -300.0), 10000.0);
});

test('BouncingScrollPhysics selects correct spring for createBallisticSimulation', () {
const physics = BouncingScrollPhysics();

final ScrollMetrics metrics = FixedScrollMetrics(
minScrollExtent: 0.0,
maxScrollExtent: 1000.0,
pixels: -500.0,
viewportDimension: 500.0,
axisDirection: AxisDirection.down,
devicePixelRatio: 1.0,
);

final Simulation simStationary = physics.createBallisticSimulation(metrics, 0.0)!;
final Simulation simMoving = physics.createBallisticSimulation(metrics, -100.0)!;

expect(simStationary, isA<BouncingScrollSimulation>());
expect(simMoving, isA<BouncingScrollSimulation>());

// Stationary simulation should follow the expected spring trajectory.
expect(simStationary.x(0.1), closeTo(-185.7511436536831, 0.01));
expect(simStationary.x(0.2), closeTo(-69.00628466755506, 0.01));
expect(simStationary.x(0.3), closeTo(-25.635736232654022, 0.01));
expect(simStationary.x(0.4), closeTo(-9.523639409892818, 0.01));

final double xStationary = simStationary.x(0.2);
final double xMoving = simMoving.x(0.2);

// Stationary and moving simulations should produce different positions.
expect(xStationary, isNot(closeTo(xMoving, precisionErrorTolerance)));
});
}
Loading