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.swift—SplashScreenStateManager, the@Observable @MainActorclass 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 inAssets.xcassets. It's masked to a 200×200 continuous rounded rectangle, so a square image looks best. - Title & tagline — edit the two
Textviews inSplashScreenView. Wrap the strings inString(localized:)if you ship multiple languages. - Background —
Color.accentdrives 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))indismissSplashScreenAfterDelay(), and the zoom-out length with theTask.sleepinsideSplashScreenStateManager.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.