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.asymmetriccreates different entry and exit animations for the same view - How to collect multi-step form data with
@Stateand 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
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
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
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
IntroViewas the root coordinator —IntroViewreads only one piece of state:signed_in. It shows eitherOnboardingVieworProfileViewbased on that boolean. All navigation logic lives here; the children just do their job and flip the flag when done.AnyTransition.asymmetricinOnboardingView— Stored as aletconstant 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.switchinsideZStackfor step routing — Theswitch onboardingStateinside the innerZStackpicks which computed property to display. Each case returns a view tagged with.transition(transition). SwiftUI animates the swap whenonboardingStatechanges insidewithAnimation.guardvalidation inhandleNextButtonPressed()— 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 andreturns, preventingonboardingStatefrom incrementing.signIn()— persisting all data atomically — All@AppStoragewrites 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). SettingcurrentUserSignedIn = trueinsidewithAnimation(.spring())causesIntroViewto animate the ProfileView sliding up.ProfileView.signOut()— Sets all@AppStoragekeys tonil, erasing the persisted data. SettingcurrentUserSignedIn = falsetriggersIntroViewto animateOnboardingViewback in. This means the onboarding flow is fully reversible — same architecture, same transitions, opposite direction.Opposite transitions in
IntroView—ProfileViewhasinsertion: .move(edge: .bottom)(slides up into view) andremoval: .move(edge: .top)(slides up off screen).OnboardingViewis 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 @StateonboardingState 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 animationonboardingState += 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
@AppStorageboolean as the "signed in" gate —IntroViewreads it and conditionally renders either the onboarding flow or the main app content. AnyTransition.asymmetriccreates directional animations that make multi-step forms feel like physical page-turning — always wrap state changes withwithAnimationto activate the transitions.- Collect form data in ephemeral
@Stateduring onboarding and persist it to@AppStorageonly on final submission — this ensures partial data is never written to disk.
Last updated: June 27, 2026