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-emptyColor.clearhost 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 oneFeature(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
featuresarray andappName. 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
ScrollViewinOnboardingSheetContentwith aTabView { ... }.tabViewStyle(.page), advancing throughfeaturesand callingonCompleteon the last page. - Localize it.
titleanddescriptionare plain strings — wrap them inString(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 fromonAppearand insideonComplete.
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.
Related
- ContentView — gates the app on
onboardingCompleteand decides when onboarding shows. - Feature Model — the
Featurestruct that backs each onboarding screen. - Settings —
SettingsManager, which persistsonboardingComplete; callresetAllSettings()to replay onboarding. - LandingView — where the user lands the instant onboarding completes.
- Analytics — for instrumenting the onboarding funnel.