Transitions and matched geometry
Views arriving and leaving, and views that appear to move between them
A transition describes how a view enters and leaves the tree, as opposed to how its properties change while it stays.
if showDetail {
DetailPanel()
.transition(.move(edge: .bottom).combined(with: .opacity))
}
Two rules decide whether this works, and both catch people.
A transition needs an animation. The transition says what the entrance looks like; an
animation says the change should be interpolated at all. Without withAnimation around the state
change, or .animation(_:value:) nearby, the view appears instantly and the transition never runs.
The transition goes on the view that appears, inside the if — not on the container. Put it on
the VStack outside and it describes the stack’s own arrival, which never happens.
The built-ins compose
.opacity, .scale, .slide, .move(edge:), .push(from:), .offset and .blurReplace combine
with .combined(with:), and asymmetric entrances and exits are their own case:
.transition(.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .opacity
))
That pattern — slide in, fade out — is worth knowing because it is what most navigation-feeling UI actually wants. A view sliding out the way it came in usually looks like a mistake being undone.
Warning
A transition on a view inside a List or LazyVStack frequently does nothing. Lazy containers
create and destroy rows for their own reasons, and the insertion you meant is indistinguishable
from a row being realised. Animate the data change with withAnimation and let the list run its
own row animations.
Writing one
A transition is a pair of modifiers applied at the two ends, which ViewModifier plus
AnyTransition.modifier expresses directly:
struct Tilt: ViewModifier {
let angle: Double
func body(content: Content) -> some View {
content
.rotationEffect(.degrees(angle))
.opacity(angle == 0 ? 1 : 0)
}
}
extension AnyTransition {
static var tilt: AnyTransition {
.modifier(active: Tilt(angle: 20), identity: Tilt(angle: 0))
}
}
active is the state the view is in when absent — before it enters, and after it leaves.
identity is the state when it is fully present. SwiftUI interpolates between the two.
matchedGeometryEffect
Sometimes a view should not appear and disappear at all — it should look like the same element moving from one place to another. A thumbnail in a grid expanding into a full-screen image, for instance. Structurally those are two different views in two different parts of the tree, so a transition would fade one out and the other in.
matchedGeometryEffect bridges that:
struct Gallery: View {
@Namespace private var namespace
@State private var selected: Photo?
var body: some View {
ZStack {
if let photo = selected {
FullImage(photo: photo)
.matchedGeometryEffect(id: photo.id, in: namespace)
.onTapGesture { withAnimation(.spring) { selected = nil } }
} else {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
Thumbnail(photo: photo)
.matchedGeometryEffect(id: photo.id, in: namespace)
.onTapGesture { withAnimation(.spring) { selected = photo } }
}
}
}
}
}
}
Both views declare the same id in the same @Namespace. When one leaves and the other arrives,
SwiftUI interpolates the geometry — position, size — between them, so the thumbnail appears to fly
into place.
The rules that make it work:
- The
idmust match, and the namespace must be shared.@Namespacein the common ancestor, passed down if the views are in separate types. - Exactly two views may hold an id at once, and normally only one should be present at a time. Three views with the same id, or two visible simultaneously, produces a runtime warning and geometry that jumps.
- It must be inside
withAnimation. Like transitions, it interpolates nothing without one. - It matches geometry, not content. The thumbnail’s image and the full image are still different
views drawing different things; only the frame is interpolated. If the content differs visually,
add
.transition(.opacity)so the crossfade covers it.
Tip
When a matched-geometry animation looks wrong, the cause is nearly always that both views exist
at once. Put them in genuinely exclusive branches of an if/else rather than hiding one with
.opacity(0) — a hidden view is still present, still holds the id, and still competes for the
geometry.
Phase and keyframe animators
For multi-step animations that are not driven by state, two purpose-built tools beat chaining
withAnimation calls with delays:
Image(systemName: "bell")
.phaseAnimator([0, -20, 20, 0], trigger: unreadCount) { view, angle in
view.rotationEffect(.degrees(angle))
}
phaseAnimator steps through discrete phases; keyframeAnimator drives several properties on
independent timelines. Both are the right answer for a shake, a pulse or a celebratory bounce —
effects that are a fixed sequence rather than a journey between two states.