Home

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 id must match, and the namespace must be shared. @Namespace in 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.