Skip to content

Add withDurationAndBounce to SpringDescription #164411

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

Merged
merged 9 commits into from
Mar 11, 2025

Conversation

bernaferrari
Copy link
Contributor

@bernaferrari bernaferrari commented Feb 28, 2025

Part of #152587

Description:

With withDurationAndBounce (we could also rename to withDuration), the user only has to worry about a single attribute: the bounce (and duration, but they would have to worry with duration anyway. If they don't, there is a default value already). The standard SpringDescription has 3 values, so it is way more abstract. This should help a lot people to make beautiful spring animations using Flutter.

image

Negative bounce:

I didn't enable bounce to be negative because the behavior is super tricky. I don't know what formula Apple is using, but seems like it is not public. There are many different formulas we can use, including the one provided on the original issue, but then there is the risk of people complaining it works differently than SwiftUI. I need to check if other projects (react-spring, framer motion) support negative bounce, but feels like this is something 99.9999% of people wouldn't expect or use, so I think we are safe. I couldn't find a single usage of negative bounce on Swift in all GitHub (without a duration, using code-search, vs 5k cases with positive values). Not even sure the todo is needed, but won't hurt.

Comparison

Dart vs Swift testing results
 testWidgets('Spring Simulation Tests - Matching SwiftUI', (WidgetTester tester) async {
      // Test cases matching the Swift code's ranges
      List<({Duration duration, double bounce})> testCases = [
        (duration: const Duration(milliseconds: 100), bounce: 0.0),
        (duration: const Duration(milliseconds: 100), bounce: 0.3),
        (duration: const Duration(milliseconds: 100), bounce: 0.8),
        (duration: const Duration(milliseconds: 100), bounce: 1.0),
        (duration: const Duration(milliseconds: 500), bounce: 0.0),
        (duration: const Duration(milliseconds: 500), bounce: 0.3),
        (duration: const Duration(milliseconds: 500), bounce: 0.8),
        (duration: const Duration(milliseconds: 500), bounce: 1.0),
        (duration: const Duration(milliseconds: 1000), bounce: 0.0),
        (duration: const Duration(milliseconds: 1000), bounce: 0.3),
        (duration: const Duration(milliseconds: 1000), bounce: 0.8),
        (duration: const Duration(milliseconds: 1000), bounce: 1.0),
        (duration: const Duration(milliseconds: 2000), bounce: 0.0),
        (duration: const Duration(milliseconds: 2000), bounce: 0.3),
        (duration: const Duration(milliseconds: 2000), bounce: 0.8),
        (duration: const Duration(milliseconds: 2000), bounce: 1.0),
      ];

      for (final testCase in testCases) {
        SpringDescription springDesc = SpringDescription.withDurationAndBounce(
          duration: testCase.duration,
          bounce: testCase.bounce,
        );

        print(
          'Duration: ${testCase.duration.inMilliseconds / 1000}, Bounce: ${testCase.bounce}, Mass: ${springDesc.mass}, Stiffness: ${springDesc.stiffness}, Damping: ${springDesc.damping}',
        );
      }
    });

Output:

Duration: 0.1, Bounce: 0.0, Mass: 1.0, Stiffness: 3947.8417604357423, Damping: 125.66370614359171
Duration: 0.1, Bounce: 0.3, Mass: 1.0, Stiffness: 3947.8417604357423, Damping: 87.9645943005142
Duration: 0.1, Bounce: 0.8, Mass: 1.0, Stiffness: 3947.8417604357423, Damping: 25.132741228718338
Duration: 0.1, Bounce: 1.0, Mass: 1.0, Stiffness: 3947.8417604357423, Damping: 0.0
Duration: 0.5, Bounce: 0.0, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 25.132741228718345
Duration: 0.5, Bounce: 0.3, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 17.59291886010284
Duration: 0.5, Bounce: 0.8, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 5.026548245743668
Duration: 0.5, Bounce: 1.0, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 0.0
Duration: 1.0, Bounce: 0.0, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 12.566370614359172
Duration: 1.0, Bounce: 0.3, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 8.79645943005142
Duration: 1.0, Bounce: 0.8, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 2.513274122871834
Duration: 1.0, Bounce: 1.0, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 0.0
Duration: 2.0, Bounce: 0.0, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 6.283185307179586
Duration: 2.0, Bounce: 0.3, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 4.39822971502571
Duration: 2.0, Bounce: 0.8, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 1.256637061435917
Duration: 2.0, Bounce: 1.0, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 0.0

Swift:

import SwiftUI
import XCTest

class SpringParameterTests: XCTestCase {

    func printSpringParameters(duration: Double, bounce: Double) {
        let spring = Spring(duration: duration, bounce: bounce) // Let SwiftUI do its thing
        print("Duration: \(duration), Bounce: \(bounce), Mass: \(spring.mass), Stiffness: \(spring.stiffness), Damping: \(spring.damping)")
    }

    func testParameterExtraction() {
        // Test a range of durations and bounces
        let durations: [Double] = [0.1, 0.5, 1.0, 2.0]
        let bounces: [Double] = [0.0, 0.3, 0.8, 1.0]

        for duration in durations {
            for bounce in bounces {
                printSpringParameters(duration: duration, bounce: bounce)
            }
        }
    }
}

Output:

Duration: 0.1, Bounce: 0.0, Mass: 1.0, Stiffness: 3947.8417604357433, Damping: 125.66370614359172
Duration: 0.1, Bounce: 0.3, Mass: 1.0, Stiffness: 3947.841760435743, Damping: 87.96459430051421
Duration: 0.1, Bounce: 0.8, Mass: 1.0, Stiffness: 3947.8417604357423, Damping: 25.132741228718338
Duration: 0.1, Bounce: 1.0, Mass: 1.0, Stiffness: 3947.8417604357433, Damping: 0.0
Duration: 0.5, Bounce: 0.0, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 25.132741228718345
Duration: 0.5, Bounce: 0.3, Mass: 1.0, Stiffness: 157.9136704174297, Damping: 17.59291886010284
Duration: 0.5, Bounce: 0.8, Mass: 1.0, Stiffness: 157.9136704174297, Damping: 5.026548245743668
Duration: 0.5, Bounce: 1.0, Mass: 1.0, Stiffness: 157.91367041742973, Damping: 0.0
Duration: 1.0, Bounce: 0.0, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 12.566370614359172
Duration: 1.0, Bounce: 0.3, Mass: 1.0, Stiffness: 39.478417604357425, Damping: 8.79645943005142
Duration: 1.0, Bounce: 0.8, Mass: 1.0, Stiffness: 39.478417604357425, Damping: 2.513274122871834
Duration: 1.0, Bounce: 1.0, Mass: 1.0, Stiffness: 39.47841760435743, Damping: 0.0
Duration: 2.0, Bounce: 0.0, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 6.283185307179586
Duration: 2.0, Bounce: 0.3, Mass: 1.0, Stiffness: 9.869604401089356, Damping: 4.39822971502571
Duration: 2.0, Bounce: 0.8, Mass: 1.0, Stiffness: 9.869604401089356, Damping: 1.256637061435917
Duration: 2.0, Bounce: 1.0, Mass: 1.0, Stiffness: 9.869604401089358, Damping: 0.0

There are minor differences which should be rounding errors.

@github-actions github-actions bot added the framework flutter/packages/flutter repository. See also f: labels. label Feb 28, 2025
@bernaferrari bernaferrari requested a review from dkwingsmt March 1, 2025 00:07
@davidhicks980
Copy link
Contributor

bernaferrari I just came across this so I'm sorry if I'm incorrect! Is the point to allow users to set a settling time, or a period time? Right now, it looks this lets users provide a period time for each oscillation, whereas I thought iOS allowed users to specify the settling time. That said, I could be completely wrong.

Screen.Recording.2025-02-28.at.9.04.12.PM.mov

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 1, 2025

It should be total animation time. In my tests it worked fine, I even compared with Apple and got the same results. Are you sure you didn't make bounce too high (a good value is 0.3) or the AnimationController duration is different from the spring duration? I wonder what code you are using. But in the benchmarks and in my own code, it is working fine.

All it does is auto generate mass, stiffness and damping for you, in a way that matches how long it should go. There is nothing very special in what I'm doing, so I wonder how you are using it. A bounce of 2, 3, 4 would be extremely aggressive (but possible), which seems like what you did.

@davidhicks980
Copy link
Contributor

I was using 0.9 before, but here's an example using 0.3. Setting the duration on the AnimationController shouldn't matter since simulations don't use the controller's duration. Let me know if I messed something up.

void main() {
  runApp(const MaterialApp(home: Scaffold(body: App())));
}

SpringDescription withDurationAndBounce({
  Duration duration = const Duration(milliseconds: 500),
  double bounce = 0.0,
}) {
  assert(duration.inMilliseconds > 0, 'Duration must be positive');
  // TODO(bernaferrari): bounce could be negative but it's tricky to guess
  // the correct formula https://github.com/flutter/flutter/issues/152587).
  assert(bounce >= 0, 'Bounce must be non-negative');

  final double durationInSeconds = duration.inMilliseconds / Duration.millisecondsPerSecond;
  const double mass = 1.0;
  final double stiffness = (4 * math.pi * math.pi * mass) / math.pow(durationInSeconds, 2);
  final double dampingRatio = 1.0 - bounce;
  final double damping = dampingRatio * 2.0 * math.sqrt(mass * stiffness);

  return SpringDescription(mass: mass, stiffness: stiffness, damping: damping);
}

class App extends StatefulWidget {
  const App({super.key});

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Scaffold(body: Demo()));
  }
}

class Demo extends StatefulWidget {
  const Demo({super.key});

  @override
  State<Demo> createState() => _DemoState();
}

class _DemoState extends State<Demo> with SingleTickerProviderStateMixin {
  late final AnimationController animationController;
  final Stopwatch _stopwatch = Stopwatch();

  @override
  void initState() {
    super.initState();
    animationController = AnimationController.unbounded(vsync: this);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        TextButton(onPressed: _toggle, child: const Text('Trigger')),
        const SizedBox(height: 100),
        Center(
          child: ScaleTransition(
            scale: animationController.drive(Tween<double>(begin: 1, end: 2)),
            child: Container(color: Colors.blue, width: 100, height: 100),
          ),
        ),
      ],
    );
  }

  void _toggle() {
    if (animationController.isForwardOrCompleted) {
      _stopwatch.start();
      animationController
          .animateBackWith(
            SpringSimulation(
              withDurationAndBounce(duration: const Duration(milliseconds: 500), bounce: 0.3),
              animationController.value,
              0,
              0,
            ),
          )
          .then((_) {
            print('Duration: ${_stopwatch.elapsedMilliseconds}');
            _stopwatch.stop();
            _stopwatch.reset();
          });
    } else {
      _stopwatch.start();
      animationController
          .animateWith(
            SpringSimulation(
              withDurationAndBounce(duration: const Duration(milliseconds: 500), bounce: 0.3),
              animationController.value,
              1.0,
              0.0,
            ),
          )
          .then((_) {
            print('Duration: ${_stopwatch.elapsedMilliseconds}');
            _stopwatch.stop();
            _stopwatch.reset();
          });
    }
  }
}

@bernaferrari
Copy link
Contributor Author

What is wrong in your demo? Seems to work fine...

Screen.Recording.2025-03-01.at.01.46.22.mov

@davidhicks980
Copy link
Contributor

Read the console. It uses a duration of 500ms, but lasts for a full second

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 1, 2025

I checked here, I think it is perfectly fine and expected. If I had to guess, the spring effect takes some time to finish, but the perceived time and perceived effect is correct. After the demo square has settled, it takes twice the time to appear in the log.

I even wrote as a comment:

Note that the real animation duration may slightly differ for natural spring-like motion

@davidhicks980
Copy link
Contributor

I just checked and the equation you are using is for oscillation period instead of settling time.

Oscillation period equation:

final oscillationPeriod = 2 * math.pi * math.sqrt(mass / stiffness);
final oscillationPeriod ^ 2 = 4 * math.pi * math.pi * (mass / stiffness);
final stiffness = (4 * math.pi * math.pi * mass) / math.pow(oscillationPeriod, 2); // The current stiffness value

We should be solving to determine the t value for a particular tolerance:

https://www.electrical4u.com/settling-time/

t = -ln(tolerance) / decayExponent

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 1, 2025

I could update the names, but I disagree about changing how it works. I think people wants it to work like it does on Swift, and Swift works exactly the way I did. I also think it is a better UX overall, since it is the perceived time, not the actual time which is longer but impossible to see.

If I were to solve the way you want, which formula would you use?

Apple exposes:

var bounce: Double
How bouncy the spring is.
var damping: Double
Defines how the spring’s motion should be damped due to the forces of friction.
var dampingRatio: Double
The amount of drag applied, as a fraction of the amount needed to produce critical damping.
var duration: TimeInterval
The perceptual duration, which defines the pace of the spring.
var mass: Double
The mass of the object attached to the end of the spring.
var response: Double
The stiffness of the spring, defined as an approximate duration in seconds.
var settlingDuration: TimeInterval
The estimated duration required for the spring system to be considered at rest.
var stiffness: Double
The spring stiffness coefficient.

They probably don't use the settlingDuration, but if that is relevant for you, I could expose.

@davidhicks980
Copy link
Contributor

davidhicks980 commented Mar 1, 2025

So, the equation for an underdamped spring (I think that's what we want since bounce implies implies more than one oscillation) is $A(t) = A_0 e^{-\zeta\omega_n t}$ where $e^{-\zeta\omega_n t}$ is the decay. We want to know how long it takes for the decay to reach a certain value, so we are trying to solve $\text{decay} = e^{-\zeta\omega_n \cdot \text{responseTime}}$, where decay is something small like 0.001. Solving for frequency gives us $\omega_n = \frac{-\ln(\text{decayTolerance})}{\zeta \cdot \text{responseTime}}$.

$stiffness = \omega_n^2$.

$damping = 2\zeta\omega_n$

So something like this:

SpringDescription withSettlingDuration({
  required Duration duration,
  required double bounce,
  double decayTolerance = 0.001,
}) {
  // Denominator needs to be scaled because t doesn't have a unit
  double time = duration.inMilliseconds / (Duration.millisecondsPerSecond * 1.8);
  
  // Bounce == 0 corresponds to damping ratio of 1.0 (critical damping).
  // Bounce == 1 corresponds to a damping ratio of 0.1 (very underdamped).
  final double dampingRatio = 1.0 - (bounce * 0.9);
  final double toleranceValue = -math.log(decayTolerance);
  final double naturalFrequency = toleranceValue / (time * dampingRatio);
  final double stiffness = naturalFrequency * naturalFrequency;
  final double damping = 2.0 * dampingRatio * naturalFrequency;

  return SpringDescription(mass: 1, stiffness: stiffness, damping: damping);
}

But, I'm also not a reviewer and my decay knowledge is based on pk rather than engineering, so I'll let @dkwingsmt take a look. We could also just add both -- spring by settling time and oscillation time.

@bernaferrari
Copy link
Contributor Author

I'm not sure I want withSettlingDuration, but.... having settlingDuration could be useful, I guess? I don't have an usage for it, but doesn't seem like "anyone" could calculate it. If you know the formula to get the settlingDuration, I can include in the code and we can see if it survives the review step or not.

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 1, 2025

I asked every LLM out there to help me write settlingDuration. Seems like this is a very good approximation. I don't think I'll commit because I'm not sure it would be useful, but here it is for anyone interested:

double get settlingDuration {
  // Calculate discriminant and key parameters
  final double d = damping * damping - 4 * mass * stiffness;
  final double omegaN = math.sqrt(stiffness / mass);
  final double zeta = damping / (2 * math.sqrt(mass * stiffness));
  double sigma;

  if (d < 0) {
    // Underdamped
    sigma = zeta * omegaN;
    final double omegaD = omegaN * math.sqrt(1 - zeta * zeta);
    final double settlingTime = 8.0 / sigma + 1.2 / omegaD;
    return settlingTime * 1000;
  } else {
    // Critically damped or overdamped
    if (d == 0) {
      sigma = omegaN; // Critically damped
    } else {
      sigma = (damping - math.sqrt(d)) / (2 * mass); // Overdamped, slower pole
    }
    final double settlingTime = 9.425 / sigma;
    return settlingTime * 1000;
  }
}
Grok 3 Response (on how it arrived at the formula and the "9.425")

image

image

image

To address your request for a well-thought-out estimation of settling time for a spring simulation that isn’t just a replica of SwiftUI but stands on solid ground, I’ve developed a formula rooted in physics and tailored for practical use in animations, such as in Flutter. The goal is to predict when a spring’s motion effectively stops—meaning its displacement and velocity become negligibly small—across various spring configurations, ensuring smooth and natural animations without unnecessary complexity.
What is Settling Time?
Settling time is the duration it takes for a spring system to come to rest, or at least reach a point where its movement is imperceptible. In physics, this depends on how we define “rest.” For animations, we typically say the system has settled when the displacement 
|x|
 and velocity 
|v|
 fall below small thresholds, like 0.001 units or units per second. These thresholds ensure the motion is no longer noticeable to the eye or significant in computation.
The behavior of a spring depends on its properties—mass (
m
), stiffness (
k
), and damping (
c
)—which determine the damping ratio 
\zeta = \frac{c}{2 \sqrt{m k}}
 and natural frequency 
\omega_n = \sqrt{\frac{k}{m}}
. The system can be:
Underdamped (
\zeta < 1
): It oscillates, with the amplitude decaying over time.
Critically damped (
\zeta = 1
): It returns to equilibrium as fast as possible without oscillating.
Overdamped (
\zeta > 1
): It returns to equilibrium slowly, without oscillating.
Our formula needs to handle all these cases accurately.
Building the Formula
The settling time hinges on the system’s decay rate, which controls how quickly oscillations or motion diminish. Here’s how we approach each case:
Step 1: Define the Decay Rate
Underdamped: The envelope of oscillation decays as 
e^{-\sigma t}
, where 
\sigma = \zeta \omega_n
. The oscillatory frequency is 
\omega_d = \omega_n \sqrt{1 - \zeta^2}
.
Critically damped: The solution involves 
e^{-\omega_n t}
, so 
\sigma = \omega_n
.
Overdamped: The solution has two real exponents, 
e^{s_1 t}
 and 
e^{s_2 t}
, where 
s_{1,2} = \frac{-c \pm \sqrt{c^2 - 4mk}}{2m}
. The settling time is governed by the slower (smaller magnitude) rate, 
\sigma = \frac{c - \sqrt{c^2 - 4mk}}{2m}
.
The discriminant 
d = c^2 - 4mk
 tells us the damping type:
d < 0
: Underdamped
d = 0
: Critically damped
d > 0
: Overdamped
Step 2: Approximate Settling Time
A basic physics approximation for settling time is when the exponential decay reduces the motion to a fraction of its initial value, say 0.001:
t_s \approx \frac{-\ln(0.001)}{\sigma} \approx \frac{6.907}{\sigma}
But this only considers displacement and may not fully account for velocity or oscillations in underdamped systems. Animations often need a slightly longer time to feel “done.” After testing various configurations, I found that a single multiplier isn’t enough—underdamped systems need an adjustment for their oscillatory nature.
Step 3: Tailor for Animation
For underdamped systems, the motion includes both decay (
\sigma
) and oscillation (
\omega_d
). A more complete settling time accounts for both:
t_s = \frac{8.0}{\sigma} + \frac{1.2}{\omega_d}
The 
\frac{8.0}{\sigma}
 term ensures the amplitude decays sufficiently (stronger than 6.907 to cover velocity).
The 
\frac{1.2}{\omega_d}
 term adds time for a small number of oscillations (about 0.1–0.2 cycles), making the end smooth and natural.
For critically damped and overdamped systems, there’s no oscillation, so a single decay term suffices:
t_s = \frac{9.425}{\sigma}
The higher multiplier (9.425 vs. 6.907) ensures the motion fully settles, as these systems approach equilibrium asymptotically.
Why These Values?
The constants 8.0, 1.2, and 9.425 were chosen by balancing theory with practical animation needs:
8.0: Slightly above the theoretical 6.907, ensuring both displacement and velocity are negligible.
1.2: Corresponds to roughly 0.1–0.2 oscillations, enough to avoid cutting off perceptible motion.
9.425: Matches the asymptotic behavior of non-oscillatory systems, aligning with observed animation durations.
These were refined by testing across damping ratios (e.g., 
\zeta = 0.2, 0.7, 1.0
) and durations (e.g., 0.1s, 0.5s, 1.0s), ensuring errors stay below 0.05s—negligible for most animations.
Implementation in Flutter
Here’s how you’d implement this in Dart for a Flutter spring simulation:
dart
Duration getSettlingDuration(double mass, double stiffness, double damping) {
  // Calculate discriminant and key parameters
  final double d = damping * damping - 4 * mass * stiffness;
  final double omegaN = math.sqrt(stiffness / mass);
  final double zeta = damping / (2 * math.sqrt(mass * stiffness));
  double sigma;

  if (d < 0) {
    // Underdamped
    sigma = zeta * omegaN;
    final double omegaD = omegaN * math.sqrt(1 - zeta * zeta);
    final double settlingTime = 8.0 / sigma + 1.2 / omegaD;
    return Duration(milliseconds: (settlingTime * 1000).round());
  } else {
    // Critically damped or overdamped
    if (d == 0) {
      sigma = omegaN; // Critically damped
    } else {
      sigma = (damping - math.sqrt(d)) / (2 * mass); // Overdamped, slower pole
    }
    final double settlingTime = 9.425 / sigma;
    return Duration(milliseconds: (settlingTime * 1000).round());
  }
}

// Example usage
void main() {
  // Mass = 1, Stiffness = 100, Damping = 10 (underdamped)
  print(getSettlingDuration(1, 100, 10)); // ~1082ms

  // Mass = 1, Stiffness = 100, Damping = 20 (critically damped)
  print(getSettlingDuration(1, 100, 20)); // ~943ms

  // Mass = 1, Stiffness = 100, Damping = 30 (overdamped)
  print(getSettlingDuration(1, 100, 30)); // ~1259ms
}
Validation
Let’s check a few cases:
Underdamped (
m = 1, k = 100, c = 10
):
\omega_n = 10
, 
\zeta = 0.5
, 
\sigma = 5
, 
\omega_d = 8.66
t_s = \frac{8.0}{5} + \frac{1.2}{8.66} \approx 1.6 + 0.139 \approx 1.739
 s
Feels natural—oscillations decay in ~1.5s, with a small tail.
Critically damped (
m = 1, k = 100, c = 20
):
\sigma = 10
, 
t_s = \frac{9.425}{10} \approx 0.943
 s
Quick return without overshoot, matches expectation.
Overdamped (
m = 1, k = 100, c = 30
):
\sigma = \frac{30 - \sqrt{500}}{2} \approx 7.5
, 
t_s = \frac{9.425}{7.5} \approx 1.257
 s
Slower, smooth approach, as expected.
These durations align with physical intuition and animation needs, avoiding abrupt cutoffs or excessive runtime.
Why This Works
This approach is well-thought-out because it:
Grounds in Physics: Uses decay rates and oscillation frequencies directly from the spring equation.
Adapts to Context: Separates oscillatory and non-oscillatory cases with tailored formulas.
Simplifies for Use: Avoids complex logarithmic dependencies or iterative solutions, making it fast and predictable in code.
Feels Right: Adds just enough time for underdamped systems to settle naturally, enhancing animation quality.
It’s not a clone of SwiftUI but a standalone solution that respects the underlying mechanics while meeting animation demands. You can tweak the constants (8.0, 1.2, 9.425) if your app needs a different “feel,” but these values provide a robust starting point.

@dkwingsmt
Copy link
Contributor

Apple's duration is different from settling duration, and according to Apple's talk, the settling duration is a calculated property and is a closer match to the perceptual time when the oscillation ends. However, if our goal is an API that matches Apple's, then we should use duration, even though it's much shorter than settling duration. The most extreme case is when bounce = 1 where the oscillation never ends, but the duration is still finite.

By the way, after reading your discussion I think it's quite hard to explain what the duration really means, and the best way to explain it would be "as defined by Apple", which doesn't seem that useful since it can be achieved by other constructors already, making me a little hesitanting again whether we should include it in Futter or make it a separate package. I think I will discuss with a few others next week.

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 1, 2025

My reason for it to be in Flutter is because dealing with mass, stiffness and damping is so hard no one uses that. Literally no one. Having an easy to understand value that corresponds to reality would be helpful I think.

The settling duration has a few limitations:

  • Can be infinite, so wouldn't work with Duration (when reading variable), needs to be double.
  • It is harder and less trivial to calculate and correctly approximate.
  • When it is correct, half of the time it is impossible to "see" it is doing something.

I don't think we should necessarily copy Apple because it is Apple, but I think Apple made Springs accessible for anyone and Springs are one thing that feels Flutter is fighting against you, so I would love to see this fake/perceptual duration be included. I also never seen anyone complaining that this doesn't match reality, ever. So I don't think it is a problem. Not having easy to use Spring is a bigger problem.

I found over 5k usages of Swift spring with a custom bounce, but only 57 of Flutter's withDampingRatio (https://github.com/search?q=withDampingRatio(mass+language:Dart&type=code&l=Dart) because it is too hard to use.

@dkwingsmt
Copy link
Contributor

dkwingsmt commented Mar 3, 2025

I've did some research and found the formula for negative bounces. But there's a twist.

First of all, the code is as follows:

  factory SpringDescription.withDurationAndBounce({
    Duration duration = const Duration(milliseconds: 500),
    double bounce = 0.0,
  }) {
    assert(duration.inMilliseconds > 0, 'Duration must be positive');
    final double durationInSeconds = duration.inMilliseconds / Duration.millisecondsPerSecond;
    const double mass = 1.0;
    final double stiffness = (4 * math.pi * math.pi * mass) / durationInSeconds / durationInSeconds;
    final double dampingRatio = bounce > 0 ? (1.0 - bounce) : (1 / (bounce + 1));
    return SpringDescription.withDampingRatio(ratio: dampingRatio, mass: mass, stiffness: stiffness);
  }

(Surprisingly simple, I know.)

And here are some more test cases I used during the development (so that you need to create fewer)

    test('creates spring with expected parameters for given duration and bounce', () {
      SpringDescription spring;

      // Under-damped

      spring = SpringDescription.withDurationAndBounce(bounce: 0.3);

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(17.59, epsilon: 0.01));

      spring = SpringDescription.withDurationAndBounce(bounce: 0.5);

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(12.56, epsilon: 0.01));

      spring = SpringDescription.withDurationAndBounce(bounce: 0.5, duration: Duration(milliseconds: 100));

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(3947.84, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(62.83, epsilon: 0.01));

      // Over damped

      spring = SpringDescription.withDurationAndBounce(bounce: -0.5, duration: Duration(milliseconds: 100));
      for (int i = 1; i <= 10; i++) {
        final double s = i / 40;
        print(SpringSimulation(spring, 0, 1, 0).x(s));
      }

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(3947.84, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(251.33, epsilon: 0.01));
    });

I've encountered some problems while creating this test case though. Typically we just simply compare the properties such as stiffness and damping. So, here are the stiffness, damping, and damping ratio for springs with bounce -0.5 as provided by SwiftUI:

            for i in 1...10 {
              let spring = Spring(duration: CGFloat(i) / CGFloat(10), bounce: -0.5)
              print(
                "\(spring.stiffness) \(spring.damping) \(spring.dampingRatio)"
              )
            }

27634.8923230502 251.32741228718345 1.9999999999999991
6908.72308076255 125.66370614359172 1.9999999999999991
3070.5435914500226 83.77580409572782 1.9999999999999993
1727.1807701906375 62.83185307179586 1.9999999999999991
1105.395692922008 50.26548245743669 1.9999999999999998
767.6358978625057 41.88790204786391 1.9999999999999993
563.9773943479634 35.90391604102621 2.0000000000000004
431.7951925476594 31.41592653589793 1.9999999999999991
341.1715101611136 27.925268031909273 1.9999999999999991
276.348923230502 25.132741228718345 1.9999999999999998

SwiftUI says they all have a damping ratio of 2.0, which is equal to 1/(1 + bounce), and also the stiffness in this table follows the following formula:

final double stiffness = (4 * math.pi * math.pi * mass) / durationInSeconds / durationInSeconds * (2 * dampingRatio * dampingRatio - 1);

which is just one factor longer than the formula of positive bounces.

However, if we calculate the damping ratio with the formula from the physics class, zeta = c / 2 sqrt(km), then zeta = 0.7559. At least someone among them 3 is wrong.

It turns out that if I remove the extra factor of stiffness and trust the damping ratio (which is the code I posted at the top), it yields the same spring. To prove it, I created a spring in Flutter and SwiftUI respectively and verified that their value at the given time are the same.

            let spring = Spring(duration: 0.1, bounce: -0.5)
            for i in 1...10 {
              let time = CGFloat(i) / CGFloat(40)
              print(
                "\(time) \(spring.value(target: 1.0, time: time))"
              )
            }

0.025 0.29298274625067333
0.05 0.5357276745793766
0.075 0.6952234078500645
0.1 0.7999263753547075
0.125 0.8686596803104747
0.15 0.9137803415802733
0.175 0.9434002481828403
0.2 0.9628445303022551
0.225 0.9756089225811501
0.25 0.9839882347742614
      spring = SpringDescription.withDurationAndBounce(bounce: -0.5, duration: Duration(milliseconds: 100));
      for (int i = 1; i <= 10; i++) {
        final double s = i / 40;
        print(SpringSimulation(spring, 0, 1, 0).x(s));
      }

0.2929827462506732
0.5357276745793764
0.6952234078500641
0.7999263753547071
0.8686596803104745
0.9137803415802731
0.9434002481828402
0.962844530302255
0.97560892258115
0.9839882347742614

I don't know why SwiftUI would return a wrong stiffness. But this is the story.

@bernaferrari
Copy link
Contributor Author

Fascinating. I can update the code with that when you tell me the team has agreed on this change lol

@dkwingsmt
Copy link
Contributor

We discussed and thought this was a good add! Let's go for it!

/// traits to compute the physical parameters.
///
/// - [duration]: The perceptual duration of the animation. Defaults to
/// `Duration(milliseconds: 500)`. The real animation duration may differ.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was very gentle here, with duration may differ, it will always differ.

@bernaferrari
Copy link
Contributor Author

bernaferrari commented Mar 8, 2025

Okay great!! I pushed incorporating your negative bounce. Right now we have a getter for duration and bounce. These are purely optional. I think they would be useful for anyone dealing with spring in Flutter. For example, getting an existing spring and checking what is the expected duration for it. Or checking the bounce so they can simplify their codebase. duration is a bit more useful than bounce (if I had to only choose one; duration calculation is also more complex).

Swift has many values:

  • What Flutter already has: mass, stiffness, damping
  • What this PR so far has: duration, bounce

var dampingRatio: Double
The amount of drag applied, as a fraction of the amount needed to produce critical damping.
var response: Double
The stiffness of the spring, defined as an approximate duration in seconds.
var settlingDuration: TimeInterval
The estimated duration required for the spring system to be considered at rest.

  • dampingRatio ->bounce is the inverse of dampingRatio, so we calculate it indirectly by having the bounce. We have withDampingRatio factory but we don't have a way to do the opposite ( SpringDescription.withDampingRatio({mass: 1, stiffness: 100}).dampingRatio)
  • settlingDuration -> I provided a function that gets close to Apple values and correctly estimates when it is infinite.
  • response -> no idea.

TL;DR: this PR includes duration and bounce because I thought they made sense, but I'm open to changing that so it is more or less feature-complete.

Copy link
Contributor

@dkwingsmt dkwingsmt left a comment

Choose a reason for hiding this comment

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

Generally ok. I've got some different proposals for docs.

Comment on lines 127 to 142
/// Returns the perceptual duration of the spring animation.
///
/// This getter estimates how long the spring appears to take to complete its
/// animation based on the current [mass] and [stiffness] values. This is
/// solely a perceptual estimate (the duration that the user perceives) and
/// may differ from the actual time taken by the simulation when measured
/// precisely. The real total duration can be influenced by initial
/// conditions and the [SpringSimulation.isDone] tolerance.
///
/// This is the inverse calculation of what's used in
/// [withDurationAndBounce]. The actual duration of a spring animation may
/// vary depending on initial conditions and when the
/// [SpringSimulation.isDone] tolerance is reached.
///
/// Returns a [Duration] object representing the estimated (perceptual)
/// animation time.
Copy link
Contributor

Choose a reason for hiding this comment

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

I have a few issues with this doc (I know it's a tough job), mostly because there are some repeating sentences and does not describe precisely what this property can be useful (IMO only to be used in the constructor). Also it unnecessarily involved technical details such as this being a calculated value.

The following is my attempt, which also took some words from SwiftUI's doc. Let me know what you think.

/// The duration parameter used in [SpringDescription.withDurationAndBounce].
///
/// This value defines the perceptual duration of the spring, controlling  
/// its overall pace. It is approximately equal to the time it takes for  
/// the spring to settle, but for highly bouncy springs, it instead  
/// corresponds to the oscillation period.
///
/// This duration does not represent the exact time for the spring to stop  
/// moving. For example, when [bounce] is `1`, the spring oscillates  
/// indefinitely, even though [duration] has a finite value. To determine  
/// when the motion has effectively stopped within a certain tolerance,  
/// use [SpringSimulation.isDone].
///
/// Defaults to 0.5 seconds.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks. Yeah, the repetition part had a bit of fault due to me trying to understand what duration did and some modifications we did 😅.

/// greater than 0.0 indicate increasing levels of bounciness.
double get bounce {
final double dampingRatio = damping / (2.0 * math.sqrt(mass * stiffness));
return 1.0 - dampingRatio;
Copy link
Contributor

Choose a reason for hiding this comment

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

This does not agree with

    final double dampingRatio = bounce > 0 ? (1.0 - bounce) : (1 / (bounce + 1));

Let's also add tests that reads negative bounce. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ohhhhhhh I forgot that. I'll!

Copy link
Contributor Author

@bernaferrari bernaferrari Mar 11, 2025

Choose a reason for hiding this comment

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

Now we have two tests, one for positive bounce, one for negative, both recalculate the getters

    test('creates spring with expected results', () {
      final SpringDescription spring = SpringDescription.withDurationAndBounce(bounce: 0.3);

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(17.59, epsilon: 0.01));

      // Verify that getters recalculate correctly
      expect(spring.bounce, moreOrLessEquals(0.3, epsilon: 0.0001));
      expect(spring.duration.inMilliseconds, equals(500));
    });

    test('creates spring with negative bounce', () {
      final SpringDescription spring = SpringDescription.withDurationAndBounce(bounce: -0.3);

      expect(spring.mass, equals(1.0));
      expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01));
      expect(spring.damping, moreOrLessEquals(35.90, epsilon: 0.01));

      // Verify that getters recalculate correctly
      expect(spring.bounce, moreOrLessEquals(-0.3, epsilon: 0.0001));
      expect(spring.duration.inMilliseconds, equals(500));
    });

Fix formatting

Fix comment

Fix analyzer

Remove math

Improve docs
@github-actions github-actions bot added a: text input Entering text in a text field or keyboard related problems tool Affects the "flutter" command-line tool. See also t: labels. engine flutter/engine repository. See also e: labels. labels Mar 11, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 15, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 15, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 16, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 17, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 20, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 25, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 25, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 26, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 27, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request Mar 28, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request May 20, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request May 20, 2025
engine-flutter-autoroll added a commit to engine-flutter-autoroll/packages that referenced this pull request May 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
framework flutter/packages/flutter repository. See also f: labels.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy