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:

  1. App launches at .firstStep; SplashScreenView plays the intro.
  2. After a one-second hold, dismissSplashScreenAfterDelay() calls dismiss().
  3. dismiss() moves to .secondStep (zoom + fade), waits one second, then sets .finished.
  4. The moment state becomes .finished, the if swaps the splash for RootView { 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.sleep durations in dismissSplashScreenAfterDelay() (initial hold) and in dismiss() (zoom-out length). They're plain Duration.seconds(...) values.
  • Restyle the screen. Edit SplashScreenView — swap the Image("Logo"), the tagline copy, or the Color.accent background. The state machine doesn't care what the view looks like.
  • Add a step. Add a case to SplashScreenStep, advance to it inside dismiss(), and handle it in updateAnimation(). Keep state private(set) so transitions stay funnelled through dismiss().
  • 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.
Previous
PurchaseManager