Views and Components
ContentView
ContentView is the first decision in the app: once the splash screen finishes, this is the view that decides whether a brand-new user sees the onboarding sheet or whether a returning user drops straight into the live, navigable app. It is intentionally tiny — a single if — because everything downstream (auth, tabs, deep links, paywalls) lives in LandingView. That separation is the point: the gate stays trivial, and the routing stays in one well-tested place.
It's a gate, not a hub
A lot of boilerplates cram onboarding, auth, and tab navigation into one giant ContentView. ShipThatApp deliberately splits them: ContentView only answers "onboarded or not?", then hands off to LandingView, which answers "signed in or not?" and owns the AppRouter navigation. Two small views beat one untestable monolith.
Where it sits in the launch flow
ShipThatAppApp shows the splash screen first, and only mounts ContentView (wrapped in RootView, the toast host) once SplashScreenStateManager reports .finished:
var body: some Scene {
WindowGroup {
if splashScreenState.state != .finished {
SplashScreenView()
.task { await dismissSplashScreenAfterDelay() }
} else {
RootView {
ContentView()
}
}
}
.environment(splashScreenState)
.environment(quickActionService)
.environment(settingsManager)
}
So by the time ContentView appears, the app-wide managers are already in the environment. ContentView's only job is the onboarding fork.
The whole view
This is the real source — there is no hidden logic:
struct ContentView: View {
@Environment(SplashScreenStateManager.self) private var splashScreenState
@Environment(SettingsManager.self) private var settingsManager
@State private var authManager = AuthManager.shared
@State private var signInViewModel = SignInViewModel()
@State private var purchaseManager = PurchaseManager()
var body: some View {
ZStack {
if settingsManager.onboardingComplete {
LandingView()
.environment(authManager)
.environment(signInViewModel)
.environment(purchaseManager)
.task {
await purchaseManager.updatePurchasedProducts()
}
} else {
OnboardingView()
}
}
}
}
What each piece does
settingsManager.onboardingCompleteis the fork. It is a stored,UserDefaults-backed property on the@ObservableSettingsManager(see Settings), so whenOnboardingViewflips it totrue, SwiftUI re-evaluatesbodyand swaps in the app automatically — no manual notification, no navigation call.ContentViewowns the long-lived view models.AuthManager.shared,SignInViewModel, andPurchaseManagerare created here as@Stateand injected intoLandingViewvia.environment(...). Creating them at this level means they survive every navigation inside the authed app and are shared by every screen that needs them..task { await purchaseManager.updatePurchasedProducts() }refreshes RevenueCat entitlements the moment the authed shell appears, so paywalls and "Pro" gating reflect reality on launch. See Purchase Manager.ZStackwraps the branches so you can layer transitions across the onboarding → app handoff.
@Observable, not @EnvironmentObject
ShipThatApp targets Swift 6 / iOS 17+ and uses the Observation framework throughout. State is injected with @Environment(SomeType.self) and .environment(value) — not the legacy @EnvironmentObject / ObservableObject. If you're extending this view, follow the same pattern so the dependency graph stays consistent.
How to use it
You normally never instantiate ContentView yourself — ShipThatAppApp does. You interact with it indirectly:
- To force-show onboarding (e.g. while building it), set
SettingsManager.shared.onboardingComplete = falseor callSettingsManager.shared.resetAllSettings(). The nextbodyevaluation drops you back intoOnboardingView. - To test the authed shell in isolation, the
#Previewshows the minimum environment it needs:
#Preview {
ContentView()
.environment(SplashScreenStateManager())
.environment(SettingsManager.shared)
}
Customize / extend it
- Add a third gate. If you need a "force update" or "maintenance" screen before everything else, add a branch above the onboarding check — keep it boolean-driven off a manager so SwiftUI does the re-rendering for you.
- Animate the handoff. Because both branches live in a
ZStack, you can attach a.transition(...)and wrap the toggle inwithAnimation(insideOnboardingView's completion) for a polished onboarding → app reveal. - Inject more shared dependencies. If a new feature needs an app-lifetime model, create it as
@Statehere and pass it down with.environment(...), exactly likeauthManagerandpurchaseManager. Don't reach for new singletons unless the model is genuinely global.
Related
- LandingView (main app flow) — the auth fork, the
TabView, and the AppRouter thatContentViewhands off to. - Onboarding — the screen shown when
onboardingCompleteisfalse, and how it flips the flag. - Settings —
SettingsManager, the source of truth foronboardingComplete. - Authentication — what happens after onboarding, inside
LandingView.