Features

Splash Screen

The splash screen is the first frame your users see, and ShipThatApp treats it as a real piece of UI rather than a static placeholder. Instead of a frozen LaunchScreen.storyboard, you get an animated, state-driven SwiftUI view that pulses your logo, then rockets it off-screen with a fade as the app hands off to its real content.

It is small, self-contained, and modern: a three-step state machine in an @Observable @MainActor manager, driven by a timer, with zero Combine and zero ObservableObject. Swap the logo and one line of copy and it's yours.

How it fits together

Three files, one job:

  • Models/SplashScreenStep.swift — the state enum: .firstStep, .secondStep, .finished.
  • Services/SplashScreen/SplashScreenManager.swiftSplashScreenStateManager, the @Observable @MainActor class that owns the current step and drives the transition.
  • Views/SplashScreenView.swift — the SwiftUI view that animates in response to the step.

The app entry point (ShipThatAppApp.swift) wires them together: it shows the splash while the manager isn't .finished, then swaps to the real root view.

Modern stack, no Combine

The manager is @Observable @MainActor and the view reads it with @Environment(SplashScreenStateManager.self) — the current Swift 6 / iOS 17+ pattern. Older docs and tutorials show an ObservableObject + @EnvironmentObject version; this template does not use that.

The state machine

SplashScreenStep is the entire vocabulary:

enum SplashScreenStep {
    case firstStep
    case secondStep
    case finished
}

SplashScreenStateManager owns the current step and exposes a single dismiss() method. Note it is fully concurrency-safe (@MainActor) and observable (@Observable), and state is private(set) — only the manager can advance it:

/// Manages the state transitions for the app's splash screen animation.
@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
        }
    }
}

Calling dismiss() moves to .secondStep (which triggers the zoom-and-fade animation), waits one second for that animation to play, then sets .finished — at which point the app reveals its real content.

The animated view

SplashScreenView reads the manager from the environment and animates three @State flags off a 0.5s timer. When the step is .firstStep it gently pulses the logo; when it becomes .secondStep it scales the logo up 10× while sliding it down and fading the whole screen out.

struct SplashScreenView: View {

    @Environment(SplashScreenStateManager.self) private var splashScreenState

    @State private var firstAnimation = false
    @State private var secondAnimation = false
    @State private var startFadeoutAnimation = false

    private let animationTimer = Timer
        .publish(every: 0.5, on: .current, in: .common)
        .autoconnect()

    private func updateAnimation() {
        switch splashScreenState.state {
        case .firstStep:
            withAnimation(.easeInOut(duration: 0.9)) {
                firstAnimation.toggle()
            }
        case .secondStep:
            if secondAnimation == false {
                withAnimation(.linear) {
                    self.secondAnimation = true
                    startFadeoutAnimation = true
                }
            }
        case .finished:
            break
        }
    }

The body stacks your logo over the accent color, with the title and tagline beneath. The animation flags drive scaleEffect and offset so the logo breathes, then launches:

var body: some View {
    ZStack {
        Color.accent
            .ignoresSafeArea()

        VStack {
            Image("Logo")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 200, height: 200)
                .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
                .scaleEffect(firstAnimation ? 0.8 : 1)
                .scaleEffect(secondAnimation ? 10 : 1)
                .offset(y: secondAnimation ? 400 : 0)

            Text("Ship That App")
                .font(.largeTitle)
                .fontWeight(.bold)
                .padding(.top, 20)

            Text("Blast Off with a Bang 🚀: Less Setup, More Skyrocketing!")
                .font(.footnote)
                .fontWeight(.medium)
                .padding(.top, 4)
                .padding(.horizontal, 40.0)
                .multilineTextAlignment(.center)
        }
        .foregroundStyle(.white)
        .onReceive(animationTimer) { _ in
            updateAnimation()
        }
        .opacity(startFadeoutAnimation ? 0 : 1)
    }
}

Wiring it into the app lifecycle

ShipThatAppApp owns the manager as @State, shows the splash until the step is .finished, and kicks off the dismissal with a .task. Once finished, it hands off to RootView { ContentView() }:

@main
struct ShipThatAppApp: App {
    @State private var splashScreenState = SplashScreenStateManager()
    // ...

    var body: some Scene {
        WindowGroup {
            if splashScreenState.state != .finished {
                SplashScreenView()
                    .task {
                        await dismissSplashScreenAfterDelay()
                    }
            } else {
                RootView {
                    ContentView()
                }
            }
        }
        .environment(splashScreenState)
        // ...
    }

    /// Dismisses the splash screen after a delay.
    private func dismissSplashScreenAfterDelay() async {
        try? await Task.sleep(for: .seconds(1))
        splashScreenState.dismiss()
    }
}

So the full timeline is: 1s of logo pulse (dismissSplashScreenAfterDelay) → dismiss() fires → zoom-and-fade plays for 1s (.secondStep) → .finished, and LandingView takes over.

Customize it

Most apps only touch a few things:

  • Logo — replace the "Logo" asset in Assets.xcassets. It's masked to a 200×200 continuous rounded rectangle, so a square image looks best.
  • Title & tagline — edit the two Text views in SplashScreenView. Wrap the strings in String(localized:) if you ship multiple languages.
  • BackgroundColor.accent drives the backdrop; change your accent color in the asset catalog and the splash follows.
  • Total duration — change the initial pulse with Task.sleep(for: .seconds(1)) in dismissSplashScreenAfterDelay(), and the zoom-out length with the Task.sleep inside SplashScreenStateManager.dismiss().

Tune the motion, keep the contract

You can freely retime or restyle the animations. The one contract to preserve: dismiss() must eventually set state = .finished, because that's the signal ShipThatAppApp waits on to reveal the app. If you add a .thirdStep, advance it to .finished at the end.

Where to go next

The splash is the entry to a sequence: launch → splash → LandingView, which routes to sign-in or the tabbed app via AppRouter. If this is a user's fourth-plus session, that same landing flow may also trigger a review request. Branding the launch screen pairs naturally with renaming the project when you make the template your own.

Previous
Configurations