-
Notifications
You must be signed in to change notification settings - Fork 28.9k
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
Conversation
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 |
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. |
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();
});
}
}
} |
What is wrong in your demo? Seems to work fine... Screen.Recording.2025-03-01.at.01.46.22.mov |
Read the console. It uses a duration of 500ms, but lasts for a full second |
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:
|
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); We should be solving to determine the t value for a particular tolerance: https://www.electrical4u.com/settling-time/ t = -ln(tolerance) / decayExponent |
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 They probably don't use the settlingDuration, but if that is relevant for you, I could expose. |
So, the equation for an underdamped spring (I think that's what we want since bounce implies implies more than one oscillation) is 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. |
I'm not sure I want |
I asked every LLM out there to help me write 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")
|
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. |
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:
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. |
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 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, 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. |
Fascinating. I can update the code with that when you tell me the team has agreed on this change lol |
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. |
There was a problem hiding this comment.
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.
Okay great!! I pushed incorporating your negative bounce. Right now we have a getter for Swift has many values:
var dampingRatio: Double
TL;DR: this PR includes |
There was a problem hiding this 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.
/// 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
. :)
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
Part of #152587
Description:
With
withDurationAndBounce
(we could also rename towithDuration
), 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 standardSpringDescription
has 3 values, so it is way more abstract. This should help a lot people to make beautiful spring animations using Flutter.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
Output:
Swift:
Output:
There are minor differences which should be rounding errors.