From 2dd43281805d7d6bede3eb2ca5bf30e7308be76a Mon Sep 17 00:00:00 2001 From: Dev Ttangkong Date: Thu, 4 Jun 2026 23:41:34 +0900 Subject: [PATCH 1/2] Improve iOS scroll behavior by implementing rubber band bounce in BouncingScrollPhysics --- .../lib/src/widgets/scroll_physics.dart | 48 ++++++++++++++++++- ...range_maintaining_scroll_physics_test.dart | 4 +- .../test/widgets/scroll_physics_test.dart | 31 ++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index b00a5b5c1bca0..70bfdc8602b6f 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -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); @@ -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, diff --git a/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart b/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart index 38c66ebcb6d1d..c4ff50d1bf19a 100644 --- a/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart +++ b/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart @@ -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); }); } diff --git a/packages/flutter/test/widgets/scroll_physics_test.dart b/packages/flutter/test/widgets/scroll_physics_test.dart index a84476bc2731b..2d3d9b48c18c2 100644 --- a/packages/flutter/test/widgets/scroll_physics_test.dart +++ b/packages/flutter/test/widgets/scroll_physics_test.dart @@ -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()); + expect(simMoving, isA()); + + // 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))); + }); } From e585d6a2d52ae38331cd5bff90607c7a8b80149b Mon Sep 17 00:00:00 2001 From: Dev Ttangkong Date: Thu, 4 Jun 2026 23:51:04 +0900 Subject: [PATCH 2/2] Fix stationary boundary logic and update overscroll test frames --- packages/flutter/lib/src/widgets/scroll_physics.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart index 70bfdc8602b6f..94e4b83b90f77 100644 --- a/packages/flutter/lib/src/widgets/scroll_physics.dart +++ b/packages/flutter/lib/src/widgets/scroll_physics.dart @@ -797,7 +797,7 @@ class BouncingScrollPhysics extends ScrollPhysics { @override Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { final Tolerance tolerance = toleranceFor(position); - final bool isStationary = velocity.abs() <= tolerance.velocity; + final bool isStationary = velocity.abs() < tolerance.velocity; final bool isRubberBand = isStationary && position.outOfRange; if (!isStationary || position.outOfRange) {