Skip to content

Manage user onboarding with @AppStorage and Transitions in SwiftUI | SwiftUI Bootcamp #53

Most apps need to show an onboarding flow the first time a user opens them, and skip it on every subsequent launch. This lesson combines @AppStorage for persistent sign-in state, @State for ephemeral step tracking, and AnyTransition for animated slide transitions — producing a complete, production-quality onboarding system across three coordinated files.

What You'll Learn

  • How to use @AppStorage("signed_in") as the gate that controls whether onboarding or the main app appears
  • How AnyTransition.asymmetric creates different entry and exit animations for the same view
  • How to collect multi-step form data with @State and persist it all at once on final submission
  • How withAnimation(.spring()) makes state-driven view swaps feel fluid and native

Mental Model

Think of the three-file structure as a hotel receptionist (IntroView), a check-in form (OnboardingView), and a guest room (ProfileView). The receptionist checks whether a guest key (signed_in) already exists. If it does, the guest walks straight to their room. If not, they fill out the check-in form first. Once they've completed the form, they get their key and go directly to their room from now on — the form is never shown again.

AnyTransition.asymmetric is like the hotel's two doors: one for arriving guests (enters from the right), one for departing guests (exits to the left). Even though the same door frame is reused, the swing direction is different depending on whether you're coming or going. This makes the animation feel directional and purposeful rather than just fading in and out.

Detailed Explanation

The architecture here is a root coordinator pattern: IntroView reads a single @AppStorage boolean to decide which "world" the user is in. When currentUserSignedIn is false, OnboardingView fills the screen. When it becomes true, ProfileView takes over. Wrapping this state change in withAnimation(.spring()) makes the swap animated. The actual boolean lives in UserDefaults, so it persists across launches.

OnboardingView uses an integer onboardingState (0–3) to track which step the user is on. Each step is a separate private var computed property (extension split for readability) with its own UI. A single ZStack switches between them using a switch statement, and the bottomButton advances the state. The real onboarding data (name, age, gender) is collected in local @State variables and only written to @AppStorage in the final signIn() call — validating data before persisting it.

AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)) defines a directional slide: new content slides in from the right, old content exits to the left. This mimics a physical page-turning metaphor — a natural, universally understood directional metaphor for "moving forward." The transition is stored as a constant to avoid recreating it on every render.

Input validation using guard in handleNextButtonPressed() demonstrates that the "Next" button shouldn't blindly advance if required fields are missing. The guard checks minimum name length and gender selection, showing an alert if validation fails. This is the correct pattern for multi-step form validation: validate immediately before advancing, never at the end.

Code Structure

The sample spans three files. IntroView.swift is the root: it reads signed_in and conditionally renders either OnboardingView or ProfileView with opposite asymmetric transitions. OnboardingView.swift is the multi-step form: it manages onboardingState, collects inputs, validates, and calls signIn(). ProfileView.swift reads the stored user data and provides a "Sign Out" button that clears all @AppStorage values and returns the user to onboarding.

Complete Code

OnboardingView.swift

swift
import SwiftUI

struct OnboardingView: View {
    
    // Onboarding states:
    /*
     0 - Welcome screen
     1 - Add name
     2 - Add age
     3 - Add gender
     */
    @State var onboardingState: Int = 0 // tracks which step is currently displayed (0–3)
    let transition: AnyTransition = .asymmetric(
        insertion: .move(edge: .trailing),  // new step slides in from the right
        removal: .move(edge: .leading))     // old step exits to the left — feels like turning pages forward
    
    // onboarding inputs
    @State var name: String = ""
    @State var age: Double = 50
    @State var gender: String = ""
    
    // for the alert
    @State var alertTitle: String = ""
    @State var showAlert: Bool = false
    
    // app storage
    @AppStorage("name") var currentUserName: String?       // persisted after sign-in
    @AppStorage("age") var currentUserAge: Int?
    @AppStorage("gender") var currentUserGender: String?
    @AppStorage("signed_in") var currentUserSignedIn: Bool = false // the master gate: true = show ProfileView

    var body: some View {
        ZStack {
            // content
            ZStack {
                switch onboardingState {
                case 0:
                    welcomeSection
                        .transition(transition) // applies the asymmetric slide transition to this step
                case 1:
                    addNameSection
                        .transition(transition)
                case 2:
                    addAgeSection
                        .transition(transition)
                case 3:
                    addGenderSection
                        .transition(transition)
                default:
                    RoundedRectangle(cornerRadius: 25.0)
                        .foregroundColor(.green)
                }
            }
            
            // buttons
            VStack {
                Spacer()
                bottomButton // extracted as a computed property to keep the body clean
            }
            .padding(30)
        }
        .alert(isPresented: $showAlert, content: {
            return Alert(title: Text(alertTitle))
        })
    }
    
}

struct OnboardingView_Previews: PreviewProvider {
    static var previews: some View {
        OnboardingView()
            .background(Color.purple)
    }
}

// MARK: COMPONENTS

extension OnboardingView {
    
    private var bottomButton: some View {
        Text(onboardingState == 0 ? "SIGN UP" :
            onboardingState == 3 ? "FINISH" :
            "NEXT"
        )
            .font(.headline)
            .foregroundColor(.purple)
            .frame(height: 55)
            .frame(maxWidth: .infinity)
            .background(Color.white)
            .cornerRadius(10)
            .animation(nil) // prevents the button label text change from animating (only the step transition should animate)
            .onTapGesture {
                handleNextButtonPressed()
            }
    }
    
    private var welcomeSection: some View {
        VStack(spacing: 40) {
            Spacer()
            Image(systemName: "heart.text.square.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 200, height: 200)
                .foregroundColor(.white)
            Text("Find your match.")
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundColor(.white)
                .overlay(
                    Capsule(style: .continuous)
                        .frame(height: 3)
                        .offset(y: 5)
                        .foregroundColor(.white)
                    , alignment: .bottom
                )
            Text("This is the #1 app for finding your match online! In this tutorial we are practicing using AppStorage and other SwiftUI techniques.")
                .fontWeight(.medium)
                .foregroundColor(.white)
            Spacer()
            Spacer()
        }
        .multilineTextAlignment(.center)
        .padding(30)
    }
    
    private var addNameSection: some View {
        VStack(spacing: 20) {
            Spacer()
            Text("What's your name?")
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundColor(.white)
            TextField("Your name here...", text: $name) // two-way binding: $name updates as the user types
                .font(.headline)
                .frame(height: 55)
                .padding(.horizontal)
                .background(Color.white)
                .cornerRadius(10)
            Spacer()
            Spacer()
        }
        .padding(30)
    }
    
    private var addAgeSection: some View {
        VStack(spacing: 20) {
            Spacer()
            Text("What's your age?")
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundColor(.white)
            
            Text("\(String(format: "%.0f", age))") // formats Double as integer for display (no decimals)
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundColor(.white)
            Slider(value: $age, in: 18...100, step: 1) // enforces minimum age of 18 at the UI level
                .accentColor(.white)
            Spacer()
            Spacer()
        }
        .padding(30)
    }
    
    private var addGenderSection: some View {
        VStack(spacing: 20) {
            Spacer()
            Text("What's your gender?")
                .font(.largeTitle)
                .fontWeight(.semibold)
                .foregroundColor(.white)
            
            
            Picker(selection: $gender,
                   label:
                    Text(gender.count > 1 ? gender : "Select a gender") // shows selection or placeholder
                    .font(.headline)
                    .foregroundColor(.purple)
                    .frame(height: 55)
                    .frame(maxWidth: .infinity)
                    .background(Color.white)
                    .cornerRadius(10)
                   ,
                   content: {
                Text("Male").tag("Male")
                Text("Female").tag("Female")
                Text("Non-Binary").tag("Non-Binary")
            })
            .pickerStyle(MenuPickerStyle()) // renders as a dropdown menu button
            Spacer()
            Spacer()
        }
        .padding(30)
    }
    
}

// MARK: FUNCTIONS

extension OnboardingView {
    
    
    func handleNextButtonPressed() {
        
        // CHECK INPUTS
        switch onboardingState {
        case 1:
            guard name.count >= 3 else { // validates before advancing; shows alert instead of proceeding
                showAlert(title: "Your name must be at least 3 characters long! 😩")
                return
            }
        case 3:
            guard gender.count > 1 else {
                showAlert(title: "Please select a gender before moving forward! 😳")
                return
            }
        default:
            break
        }
        
        
        // GO TO NEXT SECTION
        if onboardingState == 3 {
            signIn() // last step: persist data and flip signed_in flag
        } else {
            withAnimation(.spring()) { // spring animation makes the step transition feel physical
                onboardingState += 1
            }
        }
    }
    
    func signIn() {
        currentUserName = name      // persist all collected data to UserDefaults at once
        currentUserAge = Int(age)
        currentUserGender = gender
        withAnimation(.spring()) {
            currentUserSignedIn = true // this change in @AppStorage triggers IntroView to swap to ProfileView
        }
    }
    
    func showAlert(title: String) {
        alertTitle = title
        showAlert.toggle()
    }
    
    
}

IntroView.swift

swift
import SwiftUI

struct IntroView: View {
    
    @AppStorage("signed_in") var currentUserSignedIn: Bool = false // gate: false = show onboarding, true = show profile
    
    var body: some View {
        ZStack {
            // background
            RadialGradient(
                gradient: Gradient(colors: [Color(#colorLiteral(red: 0.5568627715, green: 0.3529411852, blue: 0.9686274529, alpha: 1)), Color(#colorLiteral(red: 0.3647058904, green: 0.06666667014, blue: 0.9686274529, alpha: 1))]),
                center: .topLeading,
                startRadius: 5,
                endRadius: UIScreen.main.bounds.height) // gradient covers the full screen height
                .ignoresSafeArea()
            
            if currentUserSignedIn {
                ProfileView() // slides up from the bottom when the user completes onboarding
                    .transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top)))
            } else {
                OnboardingView() // slides down from the top when the user signs out
                    .transition(.asymmetric(insertion: .move(edge: .top), removal: .move(edge: .bottom)))
            }
            
        }
    }
}

struct IntroView_Previews: PreviewProvider {
    static var previews: some View {
        IntroView()
    }
}

ProfileView.swift

swift
import SwiftUI

struct ProfileView: View {
    
    @AppStorage("name") var currentUserName: String?           // reads persisted data from UserDefaults
    @AppStorage("age") var currentUserAge: Int?
    @AppStorage("gender") var currentUserGender: String?
    @AppStorage("signed_in") var currentUserSignedIn: Bool = false
    
    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "person.circle.fill")
                .resizable()
                .scaledToFit()
                .frame(width: 150, height: 150)
            Text(currentUserName ?? "Your name here")    // nil-coalesces in case storage was cleared externally
            Text("This user is \(currentUserAge ?? 0) years old!")
            Text("Their gender is \(currentUserGender ?? "unknown")")
            
            Text("SIGN OUT")
                .foregroundColor(.white)
                .font(.headline)
                .frame(height: 55)
                .frame(maxWidth: .infinity)
                .background(Color.black)
                .cornerRadius(10)
                .onTapGesture {
                    signOut()
                }
        }
        .font(.title)
        .foregroundColor(.purple)
        .padding()
        .padding(.vertical, 40)
        .background(Color.white)
        .cornerRadius(10)
        .shadow(radius: 10)
    }
    
    func signOut() {
        currentUserName = nil     // clears each UserDefaults key, resetting the app to its initial state
        currentUserAge = nil
        currentUserGender = nil
        withAnimation(.spring()) {
            currentUserSignedIn = false // triggers IntroView to swap back to OnboardingView with animation
        }
    }
}

struct ProfileView_Previews: PreviewProvider {
    static var previews: some View {
        ProfileView()
    }
}

Code Walkthrough

  1. IntroView as the root coordinatorIntroView reads only one piece of state: signed_in. It shows either OnboardingView or ProfileView based on that boolean. All navigation logic lives here; the children just do their job and flip the flag when done.

  2. AnyTransition.asymmetric in OnboardingView — Stored as a let constant at the struct level so it isn't recreated on every render. insertion: .move(edge: .trailing) means the new step arrives from the right; removal: .move(edge: .leading) means the old step exits to the left. This gives the form a "moving forward" feel.

  3. switch inside ZStack for step routing — The switch onboardingState inside the inner ZStack picks which computed property to display. Each case returns a view tagged with .transition(transition). SwiftUI animates the swap when onboardingState changes inside withAnimation.

  4. guard validation in handleNextButtonPressed() — Validates inputs before advancing. The name guard (minimum 3 characters) runs only on step 1; the gender guard runs only on step 3. Any violation shows an alert and returns, preventing onboardingState from incrementing.

  5. signIn() — persisting all data atomically — All @AppStorage writes happen together at the end of onboarding. Data is never partially saved — either the user completes all steps (and all values persist), or they abandon onboarding (and nothing is saved). Setting currentUserSignedIn = true inside withAnimation(.spring()) causes IntroView to animate the ProfileView sliding up.

  6. ProfileView.signOut() — Sets all @AppStorage keys to nil, erasing the persisted data. Setting currentUserSignedIn = false triggers IntroView to animate OnboardingView back in. This means the onboarding flow is fully reversible — same architecture, same transitions, opposite direction.

  7. Opposite transitions in IntroViewProfileView has insertion: .move(edge: .bottom) (slides up into view) and removal: .move(edge: .top) (slides up off screen). OnboardingView is the inverse. This means signing in animates the profile up from below, and signing out animates the profile up and away — a natural vertical metaphor for logging in (ascending) and logging out (departing).

Common Mistakes

Mistake: Storing onboardingState in @AppStorage instead of @State
onboardingState is ephemeral UI state — the user should see the welcome screen again if they quit during onboarding. Persisting it would mean a user who quit midway would skip back to step 3 on next launch. Only persist the final outcomes: the collected user data and the signed_in flag.

Mistake: Forgetting withAnimation when flipping currentUserSignedIn, causing an abrupt snap instead of a slide
The @AppStorage value change triggers IntroView to re-render, but without withAnimation, the view swap is instantaneous. Always wrap the flag flip in withAnimation to trigger the transition animations defined on each conditional view.

Mistake: Not wrapping the step transition in withAnimation, causing page advances to snap without animation
onboardingState += 1 on its own changes state but triggers no animation. The .transition(transition) on each step only runs when the state change is wrapped in withAnimation. This is a common source of confusion: the transition modifier describes HOW to animate, but withAnimation is what actually triggers the animation.

Key Takeaways

  • Use a single @AppStorage boolean as the "signed in" gate — IntroView reads it and conditionally renders either the onboarding flow or the main app content.
  • AnyTransition.asymmetric creates directional animations that make multi-step forms feel like physical page-turning — always wrap state changes with withAnimation to activate the transitions.
  • Collect form data in ephemeral @State during onboarding and persist it to @AppStorage only on final submission — this ensures partial data is never written to disk.

Last updated: June 27, 2026

Released under the MIT License.