Views and Components

OnboardingView

OnboardingView is the first screen a brand-new user sees. Its job is narrow and important: show what the app does, then flip one persisted flag so the user never sees it again. ContentView gates the whole app on that flag, so onboarding is the bridge between "just installed" and "using the app."

It's deliberately data-driven — the screens are an array of Feature values, not hand-built views — so reskinning your first-run experience is editing a list, not rewriting UI.

One flag drives everything

Completion is a single line: settingsManager.onboardingComplete = true. Because SettingsManager is @Observable and that property is UserDefaults-backed, setting it persists across launches and triggers SwiftUI to swap ContentView from onboarding to LandingView automatically. No navigation calls, no notifications.

Architecture: a thin host + a data-driven sheet

The view is split into three small pieces in ShipThatApp/Views/Onboarding/OnboardingView.swift:

  • OnboardingView — a near-empty Color.clear host that presents the onboarding as a sheet and owns the completion callback.
  • OnboardingSheetContent — the actual UI: title, a scrolling list of feature rows, and the CTA.
  • FeatureRow — renders one Feature (SF Symbol + title + description).

The host presents the sheet on appear and disables interactive dismissal so users can't swipe past onboarding without completing it:

struct OnboardingView: View {
    @Environment(SettingsManager.self) private var settingsManager
    @State private var showOnboardingSheet = false

    var body: some View {
        Color.clear
            .onAppear {
                if !settingsManager.onboardingComplete {
                    showOnboardingSheet = true
                }
            }
            .sheet(isPresented: $showOnboardingSheet) {
                OnboardingSheetContent(
                    appName: appName,
                    features: features,
                    onComplete: {
                        settingsManager.onboardingComplete = true
                        showOnboardingSheet = false
                    }
                )
                .interactiveDismissDisabled()
            }
    }
}

The content is a list of features

The screens come from a typed array of Feature values. To change what onboarding pitches, you edit this array — nothing else:

let appName: String = "ShipThatApp 🚀"
let features: [Feature] = [
    Feature(title: "Magic Links",
            description: "with Supabase bootstrapped, so you don't have to",
            icon: "envelope.badge.shield.half.filled"),
    Feature(title: "Login with Apple",
            description: "ready to use, so you can focus on the content",
            icon: "apple.logo"),
    Feature(title: "Payments",
            description: "initial setup for RevenueCat done, just setup Apple side, and start collecting payments",
            icon: "creditcard"),
    Feature(title: "Analytics",
            description: "with TelemetryDeck bootstrapped, so you can discover user patterns and make you app better",
            icon: "chart.bar.xaxis"),
    Feature(title: "On-Device AI",
            description: "Apple Foundation Models integration for private, offline AI assistance",
            icon: "cpu")
]

The Feature model

Feature is a tiny Identifiable struct (ShipThatApp/Models/OnboardingFeature.swift) — see Feature Model for the full reference:

struct Feature: Identifiable {
    let id = UUID()
    let title: String
    let description: String
    let icon: String?
}

OnboardingSheetContent simply iterates them:

ScrollView {
    VStack(spacing: 20) {
        ForEach(features) { feature in
            FeatureRow(feature: feature)
        }
    }
    .padding(.horizontal, 24)
}

How completion works

The CTA at the bottom of the sheet calls the injected onComplete closure — that's the only exit:

Button(action: onComplete) {
    Text("Let's Start")
        .fontWeight(.semibold)
        .foregroundStyle(.white)
        .frame(maxWidth: .infinity)
        .frame(height: 54)
        .background(Color.accentColor.gradient,
                    in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}

Tapping it sets onboardingComplete = true and dismisses the sheet. On the next body evaluation, ContentView sees the flag and renders LandingView instead. Because the flag is persisted, a relaunch goes straight to the app.

Customize / extend it

  • Change the pitch. Edit the features array and appName. The list, rows, and layout adapt automatically — no view code to touch.
  • Add steps or a pager. The current design is a single scrolling sheet. To make it multi-page, replace the ScrollView in OnboardingSheetContent with a TabView { ... }.tabViewStyle(.page), advancing through features and calling onComplete on the last page.
  • Localize it. title and description are plain strings — wrap them in String(localized:) (or move them to your string catalog) to ship onboarding in multiple languages while keeping the data-driven shape.
  • Track funnel drop-off. Onboarding is the perfect place to fire TelemetryDeck events (e.g. onboardingStarted, onboardingCompleted) so you can measure activation. Send them from onAppear and inside onComplete.

Keep dismissal disabled

OnboardingView calls .interactiveDismissDisabled() on purpose: if a user swipes the sheet away without completing, onboardingComplete stays false and they're stuck looping back into onboarding. If you ever want a "Skip" path, give it its own button that also sets the flag — don't rely on swipe-to-dismiss.

  • ContentView — gates the app on onboardingComplete and decides when onboarding shows.
  • Feature Model — the Feature struct that backs each onboarding screen.
  • SettingsSettingsManager, which persists onboardingComplete; call resetAllSettings() to replay onboarding.
  • LandingView — where the user lands the instant onboarding completes.
  • Analytics — for instrumenting the onboarding funnel.
Previous
ContentView