Add dedicated rubber band spring for overscroll recovery in BouncingScrollPhysics (#187568)

> [!NOTE] 
> **Relanding** #182426.
>
> Thank you for your review and guidance on the previous PR.
>
> I'm relanding this with the fix for the test failures that caused the
issue before. *(I've updated the regression test to account for the
faster convergence rate of the new physics.)*

Fixes #181752
*(for detailed analysis, measurements, and discussion, please refer to
the original issue.)*

This pull request implements a dedicated “rubber band” physics for
overscroll snap-back in BouncingScrollPhysics on iOS to better match the
native UIScrollView feel.

<img width="1000" alt="546867255-4ad56291-9603-40c1-ae5a-3e319017c3c9"
src="https://github.com/user-attachments/assets/8e72ebff-fb8f-4613-b042-7668db15bcf5"
/>

- - -

> [!NOTE]
> This change adjusts spring constants and simulation behavior
> to improve iOS scroll fidelity.
>
> Given that this change primarily involves physics parameter tuning,
> it may reasonably qualify as test-exempt. However, I am not entirely
> certain what level of automated testing would be considered
appropriate.
>
> Please let me know if additional tests are expected,
> and I am happy to update the PR accordingly.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

**Note**: The Flutter team is currently trialing the use of [Gemini Code
Assist for
GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot should not be taken as
authoritative feedback from the Flutter team. If you find its comments
useful you can update your code accordingly, but if you are unsure or
disagree with the feedback, please feel free to wait for a Flutter team
member's review for guidance on which automated comments should be
addressed.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
Co-authored-by: Victor Sanni <victorsanniay@gmail.com>
Co-authored-by: Elliott Brooks <21270878+elliette@users.noreply.github.com>
diff --git a/packages/flutter/examples/api/test/material/app_bar/sliver_app_bar.4_test.dart b/packages/flutter/examples/api/test/material/app_bar/sliver_app_bar.4_test.dart
index c22c199..9081f20 100644
--- a/packages/flutter/examples/api/test/material/app_bar/sliver_app_bar.4_test.dart
+++ b/packages/flutter/examples/api/test/material/app_bar/sliver_app_bar.4_test.dart
@@ -31,7 +31,7 @@
     expect(find.widgetWithText(SliverAppBar, 'SliverAppBar'), findsOneWidget);
     expect(
       tester.getBottomLeft(find.text('SliverAppBar')).dy,
-      187.63506380825314,
+      185.41520737749101,
     );
 
     await tester.tap(switchFinder);
diff --git a/packages/flutter/lib/src/widgets/scroll_physics.dart b/packages/flutter/lib/src/widgets/scroll_physics.dart
index b8893b4..81145ff 100644
--- a/packages/flutter/lib/src/widgets/scroll_physics.dart
+++ b/packages/flutter/lib/src/widgets/scroll_physics.dart
@@ -731,6 +731,47 @@
   /// 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);
@@ -810,9 +851,12 @@
   @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/cupertino/refresh_test.dart b/packages/flutter/test/cupertino/refresh_test.dart
index 7e4cc72..60c81c8 100644
--- a/packages/flutter/test/cupertino/refresh_test.dart
+++ b/packages/flutter/test/cupertino/refresh_test.dart
@@ -150,34 +150,18 @@
               refreshTriggerPullDistance: 100, // default value.
               refreshIndicatorExtent: 60, // default value.
             ),
-            if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS)
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.drag,
-                pulledExtent: moreOrLessEquals(48.07979523362715),
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              )
-            else
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.drag,
-                pulledExtent: moreOrLessEquals(48.36801747187993),
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
-            if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS)
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.drag,
-                pulledExtent: moreOrLessEquals(43.98499220391114),
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              )
-            else
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.drag,
-                pulledExtent: moreOrLessEquals(44.63031931875867),
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
+            matchesBuilder(
+              refreshState: RefreshIndicatorMode.drag,
+              pulledExtent: moreOrLessEquals(41.01717797216727),
+              refreshTriggerPullDistance: 100, // default value.
+              refreshIndicatorExtent: 60, // default value.
+            ),
+            matchesBuilder(
+              refreshState: RefreshIndicatorMode.drag,
+              pulledExtent: moreOrLessEquals(33.64784129423112),
+              refreshTriggerPullDistance: 100, // default value.
+              refreshIndicatorExtent: 60, // default value.
+            ),
           ]),
         );
         // The builder isn't called again when the sliver completely goes away.
@@ -319,20 +303,12 @@
               refreshIndicatorExtent: 60, // Default value.
             ),
             equals(const RefreshTaskInvocation()),
-            if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS)
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.armed,
-                pulledExtent: moreOrLessEquals(124.87933920045268),
-                refreshTriggerPullDistance: 100, // Default value.
-                refreshIndicatorExtent: 60, // Default value.
-              )
-            else
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.armed,
-                pulledExtent: moreOrLessEquals(127.10396988577114),
-                refreshTriggerPullDistance: 100, // Default value.
-                refreshIndicatorExtent: 60, // Default value.
-              ),
+            matchesBuilder(
+              refreshState: RefreshIndicatorMode.armed,
+              pulledExtent: moreOrLessEquals(91.42693833475049),
+              refreshTriggerPullDistance: 100, // Default value.
+              refreshIndicatorExtent: 60, // Default value.
+            ),
           ]),
         );
 
@@ -382,9 +358,6 @@
       (WidgetTester tester) async {
         final error = FlutterError('Oops');
         double errorCount = 0;
-        final TargetPlatform? platform =
-            debugDefaultTargetPlatformOverride; // Will not be correct within the zone.
-
         runZonedGuarded(
           () async {
             mockHelper.refreshCompleter = Completer<void>.sync();
@@ -417,20 +390,12 @@
                   refreshTriggerPullDistance: 100, // Default value.
                 ),
                 equals(const RefreshTaskInvocation()),
-                if (platform == TargetPlatform.macOS)
-                  matchesBuilder(
-                    refreshState: RefreshIndicatorMode.armed,
-                    pulledExtent: moreOrLessEquals(124.87933920045268),
-                    refreshTriggerPullDistance: 100, // Default value.
-                    refreshIndicatorExtent: 60, // Default value.
-                  )
-                else
-                  matchesBuilder(
-                    refreshState: RefreshIndicatorMode.armed,
-                    pulledExtent: moreOrLessEquals(127.10396988577114),
-                    refreshIndicatorExtent: 60, // Default value.
-                    refreshTriggerPullDistance: 100, // Default value.
-                  ),
+                matchesBuilder(
+                  refreshState: RefreshIndicatorMode.armed,
+                  pulledExtent: moreOrLessEquals(91.42693833475049),
+                  refreshIndicatorExtent: 60, // Default value.
+                  refreshTriggerPullDistance: 100, // Default value.
+                ),
               ]),
             );
 
@@ -762,47 +727,25 @@
         // Waiting for refresh control to reach approximately 5% of height
         await tester.pump(const Duration(milliseconds: 400));
 
-        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
-          expect(
-            tester.getRect(find.widgetWithText(Center, '0')).top,
-            moreOrLessEquals(3.9543032206542765, epsilon: 4e-1),
-          );
-          expect(
-            tester.getRect(find.widgetWithText(Center, '-1')).height,
-            moreOrLessEquals(3.9543032206542765, epsilon: 4e-1),
-          );
-          expect(
-            mockHelper.invocations,
-            contains(
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.inactive,
-                pulledExtent: 3.9543032206542765, // ~5% of 60.0
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
+        expect(
+          tester.getRect(find.widgetWithText(Center, '0')).top,
+          moreOrLessEquals(1.1428367291871382, epsilon: 4e-1),
+        );
+        expect(
+          tester.getRect(find.widgetWithText(Center, '-1')).height,
+          moreOrLessEquals(1.1428367291871382, epsilon: 4e-1),
+        );
+        expect(
+          mockHelper.invocations,
+          contains(
+            matchesBuilder(
+              refreshState: RefreshIndicatorMode.inactive,
+              pulledExtent: 1.1428367291871382,
+              refreshTriggerPullDistance: 100, // default value.
+              refreshIndicatorExtent: 60, // default value.
             ),
-          );
-        } else {
-          expect(
-            tester.getRect(find.widgetWithText(Center, '0')).top,
-            moreOrLessEquals(3.0, epsilon: 4e-1),
-          );
-          expect(
-            tester.getRect(find.widgetWithText(Center, '-1')).height,
-            moreOrLessEquals(3.0, epsilon: 4e-1),
-          );
-          expect(
-            mockHelper.invocations,
-            contains(
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.inactive,
-                pulledExtent: 2.6980688300546443, // ~5% of 60.0
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
-            ),
-          );
-        }
+          ),
+        );
         expect(find.text('-1'), findsOneWidget);
       },
       variant: const TargetPlatformVariant(<TargetPlatform>{
@@ -851,39 +794,21 @@
         // Let it start going away but not fully.
         await tester.pump(const Duration(milliseconds: 100));
         // The refresh indicator is still building.
-        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
-          expect(
-            mockHelper.invocations,
-            contains(
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.done,
-                pulledExtent: 90.13497854600749,
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
+        expect(
+          mockHelper.invocations,
+          contains(
+            matchesBuilder(
+              refreshState: RefreshIndicatorMode.done,
+              pulledExtent: 55.72534309610493,
+              refreshTriggerPullDistance: 100, // default value.
+              refreshIndicatorExtent: 60, // default value.
             ),
-          );
-          expect(
-            tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
-            moreOrLessEquals(90.13497854600749),
-          );
-        } else {
-          expect(
-            mockHelper.invocations,
-            contains(
-              matchesBuilder(
-                refreshState: RefreshIndicatorMode.done,
-                pulledExtent: 91.31180913199277,
-                refreshTriggerPullDistance: 100, // default value.
-                refreshIndicatorExtent: 60, // default value.
-              ),
-            ),
-          );
-          expect(
-            tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
-            moreOrLessEquals(91.311809131992776),
-          );
-        }
+          ),
+        );
+        expect(
+          tester.getBottomLeft(find.widgetWithText(Center, '-1')).dy,
+          moreOrLessEquals(55.72534309610493),
+        );
 
         // Start another drag by an amount that would have been enough to
         // trigger another refresh if it were in the right state.
@@ -904,7 +829,7 @@
             contains(
               matchesBuilder(
                 refreshState: RefreshIndicatorMode.done,
-                pulledExtent: 118.29756539042118,
+                pulledExtent: 87.81745749545563,
                 refreshTriggerPullDistance: 100, // default value.
                 refreshIndicatorExtent: 60, // default value.
               ),
@@ -916,7 +841,7 @@
             contains(
               matchesBuilder(
                 refreshState: RefreshIndicatorMode.done,
-                pulledExtent: 147.3772721631821,
+                pulledExtent: 131.21929413745545,
                 refreshTriggerPullDistance: 100, // default value.
                 refreshIndicatorExtent: 60, // default value.
               ),
@@ -1180,27 +1105,15 @@
 
         await tester.pump(const Duration(milliseconds: 10));
 
-        if (debugDefaultTargetPlatformOverride == TargetPlatform.macOS) {
-          expect(
-            mockHelper.invocations.last,
-            matchesBuilder(
-              refreshState: RefreshIndicatorMode.done,
-              pulledExtent: moreOrLessEquals(148.36088180097366),
-              refreshTriggerPullDistance: 100.0, // Default value.
-              refreshIndicatorExtent: 60.0, // Default value.
-            ),
-          );
-        } else {
-          expect(
-            mockHelper.invocations.last,
-            matchesBuilder(
-              refreshState: RefreshIndicatorMode.done,
-              pulledExtent: moreOrLessEquals(148.6463892921364),
-              refreshTriggerPullDistance: 100.0, // Default value.
-              refreshIndicatorExtent: 60.0, // Default value.
-            ),
-          );
-        }
+        expect(
+          mockHelper.invocations.last,
+          matchesBuilder(
+            refreshState: RefreshIndicatorMode.done,
+            pulledExtent: moreOrLessEquals(135.85990823867772),
+            refreshTriggerPullDistance: 100.0, // Default value.
+            refreshIndicatorExtent: 60.0, // Default value.
+          ),
+        );
 
         await tester.pump(const Duration(seconds: 5));
         expect(find.text('-1'), findsNothing);
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 b392999..940be75 100644
--- a/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart
+++ b/packages/flutter/test/widgets/range_maintaining_scroll_physics_test.dart
@@ -362,7 +362,9 @@
     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 7ee6e00..14ad38c 100644
--- a/packages/flutter/test/widgets/scroll_physics_test.dart
+++ b/packages/flutter/test/widgets/scroll_physics_test.dart
@@ -372,6 +372,70 @@
     await tester.fling(find.text('Index 2'), const Offset(0.0, -300.0), 10000.0);
   });
 
+  group('BouncingScrollPhysics selects correct spring for createBallisticSimulation', () {
+    test('on leading edge overscroll', () {
+      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)));
+    });
+
+    test('on trailing edge overscroll', () {
+      const physics = BouncingScrollPhysics();
+
+      final ScrollMetrics metrics = FixedScrollMetrics(
+        minScrollExtent: 0.0,
+        maxScrollExtent: 1000.0,
+        pixels: 1500.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(1185.7511436536831, 0.01));
+      expect(simStationary.x(0.2), closeTo(1069.006284667555, 0.01));
+      expect(simStationary.x(0.3), closeTo(1025.635736232654, 0.01));
+      expect(simStationary.x(0.4), closeTo(1009.5236394098928, 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)));
+    });
+  });
+
   testWidgets('ScrollPhysics updates position when shouldUpdate returns true', (
     WidgetTester tester,
   ) async {