Skip to content

How to use Transition in SwiftUI | SwiftUI Bootcamp #27

Animations on existing views are great, but how do you animate a view appearing or disappearing? That's what transitions handle. This lesson shows you how to make a view slide in from the bottom and fade out — and more importantly, why a transition only works when a view is actually being inserted or removed from the hierarchy.

What You'll Learn

  • The difference between a transition (insertion/removal animation) and a regular animation (property change)
  • How withAnimation triggers transitions when a conditional view appears or disappears
  • How to compose asymmetric transitions that use different animations for insertion versus removal

Mental Model

Think of transitions like theater curtains. When a performer enters the stage, the curtain sweeps open from a specific direction — that's the insertion transition. When they leave, the curtain might fall differently — fade out, drop straight down — that's the removal transition. The performer (your view) doesn't care about the curtain; the curtain is an instruction to the stage crew (SwiftUI's animation engine) about how to handle the moment of arrival and departure.

A regular .animation() is like rearranging furniture while the performer is already on stage. A .transition() is the protocol for how they enter and exit.

Detailed Explanation

A transition in SwiftUI is an animation that runs when a view is inserted into or removed from the view hierarchy. This is distinct from an animation on a view that already exists — transitions only fire at the moment of insertion or removal.

For a transition to fire, two things must be true: the view must be conditionally included (inside an if statement), and the state change that controls the condition must be wrapped in withAnimation { }. Without withAnimation, the view still appears and disappears, but instantly — no transition runs.

.transition(.move(edge: .bottom)) makes the view slide in from the bottom on insertion and slide back out to the bottom on removal. By default, the same transition is used for both insertion and removal (but mirrored). When you want different behavior in each direction, use .asymmetric(insertion:removal:).

AnyTransition.opacity.animation(.easeInOut) shows that a transition can carry its own animation curve. This is useful in .asymmetric() where you want the insertion to use a spring (for a bouncy entrance) and the removal to use a quick fade (for a graceful exit).

The withAnimation call controls the animation that drives the transition. All transitions attached to views being inserted or removed during that state change will use this animation as their driving curve, unless the transition itself specifies its own animation (like the .animation(.easeInOut) on the opacity transition).

Code Structure

TransitionBootcamp.swift features a ZStack with a conditionally shown RoundedRectangle. The button triggers withAnimation to change the Boolean. The rectangle uses an asymmetric transition — sliding in from the bottom on insertion, fading out on removal. The .edgesIgnoringSafeArea(.bottom) allows the view to slide from below the safe area, making the entrance feel like a real bottom sheet.

Complete Code

TransitionBootcamp.swift

swift
import SwiftUI

struct TransitionBootcamp: View {
    
    @State var showView: Bool = false // controls whether the transitioning view exists in the hierarchy
    
    var body: some View {
        ZStack(alignment: .bottom) { // aligns content to the bottom so the rectangle sits at the bottom edge
            
            VStack {
                Button("BUTTON") {
                    withAnimation(.easeInOut) { // <- animation here — wrapping state change in withAnimation triggers transitions
                        showView.toggle()
                    }
                }
                Spacer()
            }
            
            if showView { // the transition fires when this condition flips — not just when the state changes
                RoundedRectangle(cornerRadius: 30)
                    .frame(height: UIScreen.main.bounds.height * 0.5) // takes up the bottom half of the screen
                    .transition(.asymmetric(
                        insertion: .move(edge: .bottom),             // slides up from below on appearance
                        removal: AnyTransition.opacity.animation(.easeInOut) // fades out when dismissed
                    ))
            }
            
            
        }
        .edgesIgnoringSafeArea(.bottom) // lets the sheet extend behind the home indicator
    }
}

struct TransitionBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        TransitionBootcamp()
    }
}

Code Walkthrough

  1. withAnimation(.easeInOut) { showView.toggle() } — This is the critical piece. Wrapping the state mutation in withAnimation tells SwiftUI to animate all changes that result from this mutation — including any transitions on views being inserted or removed.

  2. if showView { ... } — The conditional makes the RoundedRectangle insertable and removable. Without a conditional, the view always exists and has nothing to transition into or out of.

  3. ZStack(alignment: .bottom) — Aligns the RoundedRectangle to the bottom of the screen. Without this alignment, the rectangle would center in the ZStack, which would break the bottom-sheet illusion.

  4. .frame(height: UIScreen.main.bounds.height * 0.5) — Sets the height to exactly half the screen height. UIScreen.main.bounds.height is a UIKit value but is commonly used in SwiftUI for screen-size-relative dimensions.

  5. .transition(.asymmetric(insertion:removal:)) — The asymmetric transition uses a different effect for each direction. The insertion uses .move(edge: .bottom) (the sheet slides up from below). The removal uses AnyTransition.opacity.animation(.easeInOut) (it fades away instead of reversing the slide — a common UX pattern where dismissal is gentler than appearance).

  6. AnyTransition.opacity.animation(.easeInOut) — A transition with its own embedded animation curve. This .easeInOut applies specifically to the removal fade, independent of the withAnimation(.easeInOut) at the call site.

Common Mistakes

Mistake: Changing state without withAnimation and wondering why the transition doesn't run
showView.toggle() without withAnimation { } will insert or remove the view instantly. Transitions require withAnimation to fire. This is the most common reason a transition appears broken.

Mistake: Adding .transition() to a view that's always present
If the view is not conditionally shown (no if statement controlling its existence), there is no insertion or removal, and the transition never fires. Transitions only apply at the moment a view enters or exits the hierarchy.

Mistake: Using .asymmetric when a symmetric transition would be cleaner
.asymmetric is powerful but adds complexity. Start with a simple .transition(.move(edge: .bottom)) (which is symmetric — same for insert and remove) and only reach for .asymmetric when the UX clearly benefits from different behaviors in each direction.

Key Takeaways

  • Transitions animate the insertion and removal of views — they only fire when a view enters or exits the hierarchy via a conditional statement.
  • withAnimation { stateChange } is required to trigger a transition; without it, the change is instantaneous.
  • .asymmetric(insertion:removal:) lets you define different animations for appearing and disappearing, which is useful for natural-feeling bottom sheets and modals.

Last updated: June 27, 2026

Released under the MIT License.