SwiftUI
withAnimation, and the modifier that replaced it
SwiftUI gives you two ways to animate a state change, and they read almost identically at the call site:
// 1. Animate the change itself.
withAnimation(.spring(response: 0.34, dampingFraction: 0.55)) {
on.toggle()
}
// 2. Animate whatever depends on this value, whenever it changes.
Capsule()
.fill(on ? Color.green : Color.gray)
.animation(.spring(response: 0.34, dampingFraction: 0.55), value: on)The difference is where the decision lives.
withAnimation wraps a mutation: whoever changes the state decides how it
animates. The modifier attaches to a view: whoever draws the view decides.
The rule
Use the modifier when the animation belongs to the component. Use
withAnimation when it belongs to the event.
A toggle that always springs is a property of the toggle, so it goes on the view. A list that animates only when the change came from a pull-to-refresh, and not when it came from a background sync, is a property of that event — so it goes at the call site.
Why value: is not optional
The old .animation(_:) with no value was deprecated in iOS 15 for a
good reason: it animated every change to anything above it in the view
tree, including ones you never thought about. A colour change, a resize on rotation,
a parent re-laying out — all of it went through your spring.
value: makes it explicit. This animation runs when
this changes, and stays out of the way otherwise.
A worked example
A toggle whose knob overshoots. The animation is part of what the toggle is, so it lives on the view.
import SwiftUI
struct BouncyToggle: View {
@State private var on = false
var body: some View {
Button {
on.toggle()
} label: {
Capsule()
.fill(on ? Color.green : Color.gray.opacity(0.3))
.frame(width: 50, height: 30)
.overlay(alignment: on ? .trailing : .leading) {
Circle()
.fill(.white)
.frame(width: 24, height: 24)
.padding(3)
}
}
.animation(.spring(response: 0.34, dampingFraction: 0.55), value: on)
}
}Two things are being animated by that one modifier and one state change: the capsule's fill colour, and the knob's alignment from leading to trailing. You never wrote a position — SwiftUI animates the layout difference between the two states.
Reading a spring
Springs are described by feel rather than by curve:
| Argument | What it does | Useful range |
|---|---|---|
response | Roughly how long the move takes | 0.2 snappy, 0.6 lazy |
dampingFraction | How much it overshoots | Below 0.7 bounces, 1.0 doesn't |
If a change should feel mechanical, use .easeInOut. If it should feel
like an object with weight, use a spring — and if it should feel alive, drop
the damping fraction under 0.6.
When neither applies
Both of these interpolate between two states. Continuous motion — an orbit, a
clock hand, anything that is a function of time rather than a transition — is not a
state change at all, and needs TimelineView(.animation) instead. There
is an example in the
animation examples.
Try it
Open Swoop, paste BouncyToggle, and walk
dampingFraction from 0.9 down to 0.35 while tapping it. The number stops
being abstract about halfway down.
Keep reading