Four Ways to Shake
Comparing SwiftUI Animation Techniques
In SwiftUI, you can often achieve the same thing in many different ways. For example, if you wanted a shake animation in SwiftUI, there are at least four obvious ways to do it. These are not equivalent, both in terms of what’s technically possible and also the smoothness of the animation.
Here’s one example with four different implementations. Can you spot the differences?
It might be a bit hard to spot, but each of these animates in a slightly different way. In general, we want animations to have a smooth curve. For example, in the real world, when an object moves, it starts slowly, then becomes faster and finally slows down to a halt. In SwiftUI, this is typically done with the easeInOut timing curve.
When you animate the position of objects, it often “feels more natural” if that animation is somewhat like things happen in the real world. Start slowly, pick up speed, slow down again. Rather than only relying on what “feels natural”, we can also get technical about this.
If we consider the x position of our gray box as a function that takes time as the input, and the x position as the output, we can talk about the continuity of that function. Ideally, we want no sudden jumps in the output values as time increases. This is C0 continuity. Going one level higher, we also want the velocity to be continuous: if the object has a lot of motion all of a sudden, that feels unnatural. This is C1 continuity. If we then go yet another level higher, we also ideally don’t want jumps in the acceleration. This is C2 continuity. We’ll look at these in more detail.
The Four Implementations
Perhaps the simplest way to implement this in modern SwiftUI is through a phase animator. A phase animator is essentially a bunch of chained animations. In the example below, we first animate our offset from 0 to -60 (in 0.25 seconds). When that animation completes, we then animate (in 0.5 seconds, as it takes longer to travel) to 60, and when that finally completes we animate back to zero (in 0.25 seconds).
Phase Animator
struct Example: View {
@State var trigger = 0
var body: some View {
Color.gray
.frame(width: 48, height: 48)
.onTapGesture { trigger += 1 }
.phaseAnimator(
[0.0, -60, 60, 0],
trigger: trigger
) { content, offset in
content.offset(x: offset)
} animation: { offset in
.easeInOut(duration: offset == 60 ? 0.5 : 0.25)
}
}
}
In the graph, we can see that the x position of the box follows a smooth curve. We also can look at the velocity, which is the derivative of the function that plots the x position. At the points where a next phase starts, we can see that the velocity is continuous but not smooth (the slope changes abruptly). Hence, our animation is C1 continuous but not C2 continuous.
Custom Animatable
We can also create a custom Animatable view. This technique has been supported since the beginning of SwiftUI, whereas the other three techniques have been added later. The essence is to have a view that conforms to the Animatable protocol which then gets updated using an animation.
struct AnimatableShake: View, Animatable {
var progress: Double
var animatableData: Double {
get { progress }
set { progress = newValue }
}
var body: some View {
Color.gray
.frame(width: 48, height: 48)
.offset(x: -sin(progress * 2 * .pi) * 60)
}
}
struct Example: View {
@State var progress = 0.0
var body: some View {
AnimatableShake(progress: progress)
.onTapGesture {
withAnimation(.linear(duration: 1)) {
progress += 1
}
}
}
}
Because we chose a sine wave to compute the animation our derivative is the cosine. Both functions are smooth and have no sudden jumps.
Unfortunately, our animation is not C1 continuous, even though the plotted velocity looks very smooth. This is because the initial velocity is not zero, instead, it starts with a high (negative) velocity. One way to fix this would be to first apply a smoothing function to progress (e.g. UnitCurve.easeInOut).
Keyframe Animator
A third way to implement this is using a keyframe animation. This technique is a bit more low-level, but really powerful: we can have it animate through multiple values over time. We can specify multiple tracks, for example, we could animate both the rotation and the offset together in a coordinated way. In the example below, we only animate the x position, so there is no need for multiple tracks.
struct Example: View {
@State var trigger = 0
var body: some View {
Color.gray
.frame(width: 48, height: 48)
.onTapGesture { trigger += 1 }
.keyframeAnimator(
initialValue: 0.0,
trigger: trigger
) { content, offset in
content.offset(x: offset)
} keyframes: { _ in
KeyframeTrack(\.self) {
CubicKeyframe(-60, duration: 0.25)
CubicKeyframe(60, duration: 0.5)
CubicKeyframe(0, duration: 0.25)
}
}
}
}
The unique feature that keyframes have is when you specify cubic keyframes: the animator will then use that to build a Catmull-Rom spline 1 . The intuition for this is very simple: it will try to draw a smooth line through all the control points (keyframe values) we provide:
Unfortunately, these are also only C1 continuous — we can clearly see a change in the slope of the velocity at exactly the control points.
Another interesting observation is that because of how Catmull-Rom works, it overshoots a little beyond 60. For the position, this is a perfectly natural thing to happen, but for other properties, that might yield strange results (e.g. overshooting an opacity is typically not something we’d want).
Timeline View
Perhaps the approach that gives us the most control is a TimelineView. An animation timeline updates its closure at most once per animation frame, and we are free to calculate the value ourselves:
struct Example: View {
@State var startedAt: Date?
var body: some View {
TimelineView(.animation(paused: startedAt == nil)) { context in
let elapsed = startedAt.map {
context.date.timeIntervalSince($0)
} ?? 0
let progress = min(1, max(0, elapsed))
Color.gray
.frame(width: 48, height: 48)
.offset(x: -sin(progress * 2 * .pi) * 60)
.onTapGesture { startedAt = context.date }
.onChange(of: progress >= 1) { _, finished in
if finished { startedAt = nil }
}
}
}
}
As we used the same sine wave as before, both our position and velocity functions are smooth, and just like with the custom Animatable, the initial velocity of the sine’s derivative makes this only C0 continuous.
Conclusion
All of these approaches have a time and place to be used. I try to start with just regular animations, then move to phase animations. If I need more control over the curve or need to animate multiple properties in sync, I’ll use keyframe animations. I hardly ever reach for TimelineView unless I’m doing something really custom.
I’m hoping to do a few more of these blog posts and maybe even add this to the SwiftUI Field Guide. We cover animations in our SwiftUI Workshop. We have also offered specific one-day SwiftUI Animation workshops before, but don’t currently have a page for that. If your company is interested in this, get in touch.
One of the videos that got me really interested in this topic is The continuity of splines by Freya Holmér. It’s really worth a watch.
AI Disclaimer: I wrote this post myself. I used an LLM to build the visualizations and find mistakes.
-
Yes, that’s Edwin Catmull who also co-founded Pixar. ↩