Verlet Integration for Fluid Spring Animations in Swift
In my previous post, In-Process Animations and Transitions with CADisplayLink, Done Right, I outlined some of the use cases for implementing custom animations, as well as how to use CADisplayLink to help you drive the animations you create. CADisplayLink tells us when to perform updates. However, in that post we talked very little about how the actual animation was going to be implemented and how to make it look and feel truly fluid. So, let’s focus on how the value we’re animating should be updated.
As a quick recap, in-process animation techniques become necessary whenever the render server does not know how to animate the value in question, or when the behavior we need falls outside the pre-defined animation models offered by system frameworks. That includes animating custom values, interpolating between custom data structures, and implementing springs that are fully interruptible, reversible, and able to preserve velocity across retargets.
Why motion needs state
You are likely very familiar with duration-based animations and transitions, which include linear and easing curve interpolations. For these animations, we know the start value, the end value, and the duration well in advance – and these are the only 3 values we need and will ever keep as state.
When we talk about fluid animations, we are typically referring to something that is grounded in physics, and more specifically, spring motion. By fully interruptible, we usually mean that the target can change at any point without producing a discontinuity. By reversible, we mean that an animation moving toward one target can immediately be asked to move back toward its origin, or toward some entirely different destination, while preserving the velocity it had already accumulated.
That last point matters more than it may initially seem. If a modal sheet or a popover is expanding and the user immediately collapses it again, the animation should not restart from rest. The current value and the current velocity are both part of the system’s state. A reversal is therefore not a new animation, but rather the same system moving towards a new target. This is precisely why a fluid animation needs this additional state, does not have a prescribed duration, and is fundamentally dynamic.
Out-of-process techniques are generally not compatible with this definition of fluidity. The example below illustrates this: when you try to reverse a running spring animation, the out-of-process (CASpringAnimation) animation looks considerably less fluid.
Personally, I find this intuition to be incredibly important and useful, as it helps frame the problem in a different way. As we’re likely to be intuitively familiar with the simple duration-based interpolation techniques, it helps reframe the problem from “How do I play this animation?” to “How do I simulate the state of a dynamic system in time?”
The spring model
It’s not all daunting, though. The spring model is not that complicated, and there are known and well documented computational methods for spring simulation, though if math is not your thing, feel free to skip to the Swift implementation section!
If you’ve used SwiftUI or Core Animation, and animated anything using springs, you’re likely already familiar with the parameters used to configure a spring. Depending on the framework, springs are usually configured either with physical parameters such as stiffness and damping, or with higher-level parameters such as response and damping ratio.
Let \(\mathbf{x}\) be the current value, \(\mathbf{v}\) the current velocity, and \(\mathbf{x}_{\text{target}}\) the current target. If we normalize mass to \(1\), a damped spring can be written as
$$ \mathbf{a} = k(\mathbf{x}_{\text{target}} - \mathbf{x}) - c\mathbf{v}, $$
where \(k\) is stiffness and \(c\) is the damping coefficient.
The vector notation is deliberate, as the value being animated may be a scalar, a point, a size, a color, a transform component, or some custom animatable structure. The same solver works for any type that can behave like a vector.
When designing an API for working with springs, it is often more ergonomic to expose higher-level parameters such as response and damping ratio. With mass normalized to \(1\),
$$ \omega = \frac{2\pi}{\text{response}}, \qquad k = \omega^2, \qquad c = 2\zeta\omega, $$
where \(\zeta\) is the damping ratio.
Verlet Integration
Verlet integration is one of the standard ways to advance a physical system by a small time step. In its classical form, it falls out of the kinematic equations by combining the forward and backward Taylor expansions of position:
$$ x_{n+1} = 2x_n - x_{n-1} + a_n \Delta t^2. $$
This gives us a computationally cheap way to step the spring motion forward from current state and current acceleration, frame after frame, with behavior that is well suited to simulated motion. That is precisely the shape of the problem in a fluid transition and why the prior intuition was so helpful to me: we are actively evolving the system toward its current target each time we get a callback from a display link. The display link is what increments the \(n\) in the series.
For a fluid spring UI animation to feel good, however, we must also account for damping and retargeting, making the current velocity part of the state, as we saw above. So, for these purposes, we will generally use a slight modification, which lets us keep velocity explicit as well. That means our framework can preserve momentum through interruptions, reverse naturally, and continue from the current motion instead of restarting from rest.
We start with the spring acceleration
$$ a_n = k(x_{\text{target}} - x_n) - c v_n, $$
advance velocity to the midpoint
$$ v_{n+\frac12} = v_n + \tfrac12 a_n \Delta t, $$
use that midpoint velocity to advance position
$$ x_{n+1} = x_n + v_{n+\frac12} \Delta t, $$
and then finish the velocity update
$$ v_{n+1} = v_{n+\frac12} + \tfrac12 \Delta t \bigl(k(x_{\text{target}} - x_{n+1}) - c v_{n+1}\bigr). $$
Swift implementation
The Velocity Verlet implementation in Swift is surprisingly simple. In fact, if you got a bit discouraged by the math above, this will almost certainly feel simpler.
To keep the example concrete, I’m using SIMD2<Double> below as a stand-in for any animatable vector value.
struct SpringConfiguration {
let stiffness: Double
let damping: Double
init(response: TimeInterval, dampingRatio: Double) {
precondition(response > 0, "Response must be positive.")
precondition(dampingRatio >= 0, "Damping ratio must be nonnegative.")
let angularFrequency = 2.0 * .pi / response
stiffness = angularFrequency * angularFrequency
damping = 2.0 * dampingRatio * angularFrequency
}
}
typealias Vector = SIMD2<Double>
struct VelocityVerletSpring {
/// The current animated value.
var value: Vector
/// The current rate of change of `value`.
var velocity: Vector = .zero
mutating func step(
toward targetValue: Vector,
spring: SpringConfiguration,
dt: TimeInterval
) {
let halfTimeStep = 0.5 * dt
// a_n = k(x_target - x_n) - c v_n
let initialAcceleration =
(targetValue - value) * spring.stiffness
- velocity * spring.damping
// v_(n + 1/2) = v_n + 1/2 a_n Δt
let midpointVelocity = velocity + initialAcceleration * halfTimeStep
// x_(n + 1) = x_n + v_(n + 1/2) Δt
value += midpointVelocity * dt
// v_(n + 1) = v_(n + 1/2) + 1/2 Δt (k(x_target - x_(n + 1)) - c v_(n + 1))
let updatedSpringPull = (targetValue - value) * spring.stiffness
velocity =
(midpointVelocity + updatedSpringPull * halfTimeStep)
* (1.0 / (1.0 + spring.damping * halfTimeStep))
}
}
This step function is what would be called by our displayLinkDidFire callback. You can easily derive dt from successive display link timestamps, or advance the solver to targetTimestamp directly. targetTimestamp is a public property on CADisplayLink.
The use of targetTimestamp is pretty crucial. We are simulating the state at the next frame render, not the one that has already been shown, and not even the current clock time. That gives the spring an accurate time to advance to and is important for precision.
When does the animation finish?
Unlike simpler animations based on timing curves, springs do not come with a duration! In fact, mathematically a spring approaches rest asymptotically. We want our animations to finish, though, and not keep running indefinitely. Knowing when to stop the animation becomes the final piece of the puzzle we need to solve.
Coming up with a stopping point that feels right, in my opinion, is more of an art than a science.
- If you underestimate and stop the animation too early, you’re risking “snapping” the animation towards the end abruptly.
- If you wait too long, however, you risk keeping the animation alive for changes that are no longer visible to the user, and churning the system, including any side effects of the animation.
In practice, a good rest test checks two things: whether the spring is moving slowly enough, and whether it is close enough to the target that any remaining correction would be imperceptible. If the velocity is tiny but the value is still visibly displaced, the animation is not done. If the value is very close to the target but the system still has appreciable speed, it is also not done. The isAtRest method below therefore checks both a kinematic threshold and a positional threshold.
I also prefer combining a relative distance tolerance with a small absolute tolerance (something like 1% of the target value). Relative tolerances behave well for large values, while an absolute epsilon avoids pathological behavior around zero.
/// - Returns: `true` when the spring is moving slowly enough and is close enough
/// to the target to be considered settled.
func isAtRest(currentValue: Vector, targetValue: Vector) -> Bool {
// Compare squared magnitudes throughout to avoid an unnecessary square root.
let currentSpeedSquared = velocity.magnitudeSquared()
let maximumAllowedSpeedSquared =
configuration.restSpeed * configuration.restSpeed
guard currentSpeedSquared <= maximumAllowedSpeedSquared else {
return false
}
let remainingOffset = targetValue - currentValue
let remainingDistanceSquared = remainingOffset.magnitudeSquared()
// Use whichever positional tolerance is larger:
// - a small absolute tolerance for values near zero;
// - a relative tolerance that scales with the magnitude of the target.
let absoluteDistanceToleranceSquared =
configuration.restDistance * configuration.restDistance
let relativeDistanceToleranceSquared =
targetValue.magnitudeSquared()
* configuration.restDistanceFactor
* configuration.restDistanceFactor
let maximumAllowedDistanceSquared = max(
absoluteDistanceToleranceSquared,
relativeDistanceToleranceSquared
)
return remainingDistanceSquared <= maximumAllowedDistanceSquared
}
A side note on SwiftUI
SwiftUI is a great example of a framework that uses the in-process animation techniques we’re describing here to implement spring animations that are capable of both velocity preservation and retargeting. spring and interactiveSpring preserve velocity across overlapping animations, and SwiftUI’s animation model explains that spring animations merge with the previous spring state rather than composing additively like timing-curve animations. When driven by an interactive gesture, the spring also carries the velocity accumulated during interaction forward into the next target, which is exactly the property that makes interrupted and reversible motion feel continuous.
I would stop just short of saying that SwiftUI uses Velocity Verlet as its internal solver for fluid spring animations, because Apple’s public material does not state it, and it may evolve or be swapped over time. However, in my own experiments implementing an animation system whose spring motion produces consistent results with SwiftUI, Velocity Verlet coupled with a proper application of CADisplayLink matches the behavior nearly perfectly.
A comparison between a spring animation implemented using a custom Velocity Verlet solver, and the same spring simulated via SwiftUI.
Closing thoughts
The topic of manual UI animations is one that I find deeply fascinating. There is a lot of nuance to building a system like this correctly on Apple platforms, but much of that nuance is either undocumented or is only uncovered by implementation details you only discover by trial and error. With both my previous post and this one, my goal was to make some of that reasoning a bit more explicit, and to share the mental models that have been the most useful to me when thinking about building custom fluid in-process animations. Part of my motivation for writing these posts is that I am looking for a good excuse to clean up and open source the animation system behind them. At the very least, I hope these posts make the topic feel a little less mysterious.