Managers and Utils
Splash Screen State Manager
SplashScreenStateManager is the smallest manager in ShipThatApp — and a clean example of how the whole codebase models UI as state. It owns a three-step state machine that drives the animated launch screen, then transitions the app to its real content. No timers scattered through the view, no booleans toggled by hand: just a single observable state the UI renders.
Where it lives
ShipThatApp/Services/SplashScreen/SplashScreenManager.swift — backed by the SplashScreenStep enum in ShipThatApp/Models/SplashScreenStep.swift.
The whole manager
@Observable
@MainActor
final class SplashScreenStateManager {
/// The current step of the splash screen animation.
private(set) var state: SplashScreenStep = .firstStep
/// Dismisses the splash screen by transitioning through the animation steps.
func dismiss() {
Task {
state = .secondStep
try? await Task.sleep(for: Duration.seconds(1))
self.state = .finished
}
}
}
And the step it cycles through:
enum SplashScreenStep {
case firstStep // logo settles in
case secondStep // zoom + fade-out begins
case finished // hand off to the real app
}
That's the entire feature. state is private(set) — views can read it but only dismiss() can advance it, so the transition can never get into an inconsistent order.
How the state drives the animation
The manager holds state; the view interprets it. SplashScreenView reads the manager out of the environment and maps each step to an animation phase:
// SplashScreenView.swift
@Environment(SplashScreenStateManager.self) private var splashScreenState
private func updateAnimation() {
switch splashScreenState.state {
case .firstStep:
withAnimation(.easeInOut(duration: 0.9)) { firstAnimation.toggle() }
case .secondStep:
withAnimation(.linear) { secondAnimation = true; startFadeoutAnimation = true }
case .finished:
break
}
}
.firstStep gently scales the logo; .secondStep zooms it out and fades the screen; .finished is the signal for the app to move on.
How it's wired into the app
The manager is created once at the app entry point, injected into the environment, and used to gate the entire window:
// ShipThatAppApp.swift
@State private var splashScreenState = SplashScreenStateManager()
var body: some Scene {
WindowGroup {
if splashScreenState.state != .finished {
SplashScreenView()
.task { await dismissSplashScreenAfterDelay() }
} else {
RootView { ContentView() }
}
}
.environment(splashScreenState)
}
private func dismissSplashScreenAfterDelay() async {
try? await Task.sleep(for: .seconds(1))
splashScreenState.dismiss()
}
The launch sequence reads top to bottom:
- App launches at
.firstStep;SplashScreenViewplays the intro. - After a one-second hold,
dismissSplashScreenAfterDelay()callsdismiss(). dismiss()moves to.secondStep(zoom + fade), waits one second, then sets.finished.- The moment
statebecomes.finished, theifswaps the splash forRootView { ContentView() }— which then decides between onboarding and the authenticated landing flow.
Because the class is @Observable, that final swap is automatic: SwiftUI re-evaluates the Scene body the instant state changes.
This is a real launch experience, not the static LaunchScreen
iOS shows your static LaunchScreen storyboard for the split second before SwiftUI is ready. SplashScreenView is the animated screen that runs after that — the logo zoom, tagline, and fade — and SplashScreenStateManager is what choreographs it.
Customize & extend
- Change the timing. Adjust the
Task.sleepdurations indismissSplashScreenAfterDelay()(initial hold) and indismiss()(zoom-out length). They're plainDuration.seconds(...)values. - Restyle the screen. Edit
SplashScreenView— swap theImage("Logo"), the tagline copy, or theColor.accentbackground. The state machine doesn't care what the view looks like. - Add a step. Add a case to
SplashScreenStep, advance to it insidedismiss(), and handle it inupdateAnimation(). Keepstateprivate(set)so transitions stay funnelled throughdismiss(). - Gate launch on real work. Instead of a fixed sleep, await your bootstrap (a config fetch, a session refresh) before calling
dismiss()— the splash stays up exactly as long as setup takes, with no flicker.