The SwiftUI animation API is powerful but confusing. The .animation() modifier, the withAnimation { } block, matchedGeometryEffect. When do you pick which?
I have used each of these across 12 iOS apps. They each have their own use case. Here are the patterns with practical examples.
Implicit animation
The simplest and most common. A state change animates automatically.
struct ContentView: View {
@State private var isExpanded = false
var body: some View {
VStack {
Rectangle()
.frame(width: isExpanded ? 300 : 100, height: 100)
.animation(.easeInOut, value: isExpanded)
Button("Toggle") { isExpanded.toggle() }
}
}
}The .animation(_:value:) modifier. When isExpanded changes, the Rectangle’s frame animates smoothly.
When to use it:
Simple property changes (frame, opacity, color). A UI component’s own behaviour (toggle, expand). Declarative, component-level animation.
Careful: the value parameter matters. Without it, every change to the view animates.
Explicit animation
The withAnimation block animates a state change.
@State private var offset: CGFloat = 0
Button("Move") {
withAnimation(.spring()) {
offset = 200
}
}
Circle()
.offset(x: offset)Note: no .animation() modifier. The state changes inside the withAnimation block are what get animated.
Upside: you control a specific action’s animation. Other state changes do not animate.
When to use it:
The reaction to a user interaction (button tap, swipe). Complex state machine transitions. A UI update after a network response.
matchedGeometryEffect
The most powerful and most complex. A shared element transition between two different views.
struct ContentView: View {
@State private var selected: Bool = false
@Namespace private var namespace
var body: some View {
if selected {
FullscreenView(namespace: namespace)
} else {
ThumbnailView(namespace: namespace)
}
}
}
struct ThumbnailView: View {
let namespace: Namespace.ID
var body: some View {
Image("photo")
.matchedGeometryEffect(id: "photo", in: namespace)
.frame(width: 100, height: 100)
}
}
struct FullscreenView: View {
let namespace: Namespace.ID
var body: some View {
Image("photo")
.matchedGeometryEffect(id: "photo", in: namespace)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}The user taps the thumbnail, the image smoothly grows to fullscreen. iOS Photos app style.
When to use it:
Photo gallery to detail transition. List item to detail view. Tab bar to full screen. Any hero transition pattern.
In Dentii the thumbnail to detail transition on a brushing session uses this pattern. User experience is dramatically better.
Animation curves
Built-in curves in SwiftUI:
.linear: constant speed..easeIn: slow start, fast end..easeOut: fast start, slow end..easeInOut: slow-fast-slow..spring(): physical spring simulation..interactiveSpring(): user-controllable spring.
Custom timing:
.animation(.easeInOut(duration: 0.5), value: state)
.animation(.spring(response: 0.4, dampingFraction: 0.6), value: state)Spring animations feel natural. My default pick for most interactions.
Transitions
For a view’s enter and exit:
if show {
Text("Hello")
.transition(.slide)
}Built-in: .slide, .opacity, .move(edge: .top), .scale.
AsymmetricTransition:
.transition(.asymmetric(
insertion: .move(edge: .top),
removal: .move(edge: .bottom)
))Different animation for enter and exit. I use this pattern for toast notifications.
Performance considerations
Animation is expensive. Every frame is GPU work.
Golden rules:
- Transform only.
offset(),scaleEffect(),rotationEffect()are GPU accelerated. - Avoid layout changes. Changing
frame()triggers layout recomputation. - Opacity is OK. Alpha change is cheap on the GPU.
- Complex path animation is expensive. Prefer simple shapes.
Common mistakes
1. Applying animation everywhere.
.animation(.easeInOut) // Deprecated, no valueThis animates every state change. Unexpected behaviour. The value: parameter is mandatory from iOS 15+.
2. Nested animation chaos.
.animation(.easeIn)
.animation(.spring()) // Which one wins?Order confusion. The last one wins, but it is not obvious. Use one animation modifier.
3. matchedGeometryEffect namespace scope mistake.
Two views in different parents but using the same namespace. The transition does not work.
Fix: define @Namespace in a common parent and propagate it to the children.
Takeaway
Implicit animation for simple cases (70% of the use cases). Explicit animation for user-triggered transitions. matchedGeometryEffect for hero transitions.
Using the right pattern in the right scenario, SwiftUI animations match the quality of Apple’s native apps. Knowing the practical patterns is enough for great UX.