Views and Components
Lottie Views
Lottie animations turn a flat screen into something that feels alive — a celebratory burst after a purchase, a looping hero on a welcome screen, a success checkmark after a task completes. ShipThatApp ships lottie-ios already wired in, plus three example views that demonstrate the playback patterns you'll actually reach for. Instead of reading the Lottie docs and guessing at the SwiftUI API, you copy the pattern that matches your use case.
The three views aren't production screens — they're a cheat sheet you can run. Each one isolates a single playback mode so you can see exactly how looping, fixed repeat counts, and the newer .lottie (dotLottie) format work in SwiftUI.
Location: ShipThatApp/Views/Animations/Lottie1View.swift, Lottie2View.swift, Lottie3View.swift Animation assets: ShipThatApp/Lotties/ (launch.json, lottie1.json, lottie2.json, lottie3.lottie, LottieLogo1.json)
Where they live in the app
All three are surfaced through AppRouter's destination switch inside LandingView, so you can navigate to each demo and watch it run on-device:
// Animations
case .lottie1:
Lottie1View()
case .lottie2:
Lottie2View()
case .lottie3:
Lottie3View()
The same launch animation also powers WelcomeScreen, proving the pattern is reused outside the demos:
LottieView(animation: .named("launch"))
.looping()
.frame(height: 200)
.frame(maxWidth: 300)
How navigation reaches these views
The .lottie1 / .lottie2 / .lottie3 cases come from the app's central destination enum and are dispatched by AppRouter — not a HomeView. The app's root flow is LandingView + AppRouter. See Content View and Project Structure for the navigation overview.
Pattern 1 — Loop forever
Lottie1View is the simplest case: load a named JSON animation and loop it indefinitely with .looping(). Reach for this for ambient or hero animations that should never stop.
import SwiftUI
import Lottie
struct Lottie1View: View {
var body: some View {
VStack {
Text("Let's work!")
.font(.largeTitle)
LottieView(animation: .named("launch"))
.looping()
.frame(maxHeight: 300)
Text("Played on loop")
.font(.footnote)
.foregroundStyle(.primary)
}
}
}
LottieView(animation: .named("launch"))loadslaunch.jsonfrom the bundle by name (no extension)..looping()is the SwiftUI convenience that plays the whole animation on repeat.- A
.frame(maxHeight:)keeps the animation from dominating the layout — Lottie animations are happy to fill whatever space you give them.
Pattern 2 — Repeat a fixed number of times (and layer behind your logo)
Lottie2View plays an animation exactly three times, then stops — ideal for a celebration that fires once and gets out of the way. It also demonstrates composition: the Lottie plays in a ZStack on top of your app logo.
import SwiftUI
import Lottie
struct Lottie2View: View {
var body: some View {
VStack {
Text("Let's celebrate")
.font(.largeTitle)
ZStack {
Image("Logo") // Replace with your logo
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
LottieView(animation: .named("lottie2"))
.playing(loopMode: .repeat(3))
.frame(maxHeight: 200)
}
Text("Played 3 times")
.font(.footnote)
.foregroundStyle(.primary)
}
}
}
The key call is .playing(loopMode: .repeat(3)) — LottieLoopMode.repeat(_:) runs the animation a set number of cycles. Other useful modes from lottie-ios:
.playOnce— play through exactly one time..loop— repeat forever (the same effect.looping()gives you)..autoReverse/.repeatBackwards(_:)— ping-pong the playback.
Pattern 3 — Modern .lottie files with an async load and placeholder
Lottie3View uses the newer dotLottie format (lottie3.lottie) — a compressed, single-file bundle that can carry the animation plus its assets. Because dotLottie files load asynchronously, this pattern shows the LottieView trailing-closure initializer with a placeholder that displays while the file decodes.
import Lottie
import SwiftUI
struct Lottie3View: View {
var lottieAnimationSource: LottieAnimationSource?
var body: some View {
VStack {
Text("Success")
.font(.largeTitle)
LottieView {
try! await DotLottieFile.named("lottie3").animationSource
} placeholder: {
LoadingIndicator()
}
.playbackMode(.playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce)))
Text("Played once, from .lottie file")
.font(.footnote)
.foregroundStyle(.primary)
}
}
}
What's happening here:
DotLottieFile.named("lottie3")loads the.lottieasset;.animationSourcehands Lottie a playable source. The load isasync, which is why it sits inside theLottieView { ... } placeholder: { ... }initializer.- The
placeholderclosure renders while the file loads — here, a customLoadingIndicator(a spinningraysSF Symbol) defined in the same file. This is the right place to put any loading shimmer. .playbackMode(.playing(.fromProgress(0, toProgress: 1, loopMode: .playOnce)))is the most explicit way to drive playback: play from 0% to 100% of the timeline, once. Use.fromProgress/.fromFramewhen you need to play only a segment of an animation.
The bundled loading view is a tidy reusable spinner you can lift for any async state:
struct LoadingIndicator: View {
@State private var animating = false
var body: some View {
Image(systemName: "rays")
.rotationEffect(animating ? Angle.degrees(360) : .zero)
.animation(
Animation
.linear(duration: 2)
.repeatForever(autoreverses: false),
value: animating)
.onAppear { animating = true }
}
}
Choosing a pattern
| You want… | Use | Modifier |
|---|---|---|
| An always-on hero or ambient loop | Lottie1View | .looping() |
| A celebration that fires N times then stops | Lottie2View | .playing(loopMode: .repeat(3)) |
A compressed .lottie file with a loading placeholder | Lottie3View | LottieView { … } placeholder: { … } + .playbackMode(…) |
Customize / extend it
Use your own animation. Drop a .json (or .lottie) file into ShipThatApp/Lotties/, then reference it by name — no extension:
LottieView(animation: .named("your-animation"))
.looping()
Grab production-ready files from LottieFiles or export your own from After Effects with the Bodymovin plugin. Prefer the .lottie format for anything with embedded images — it's smaller and bundles everything in one file.
Wire an animation into a real flow. Lottie shines at the moments that deserve a beat of delight — drop a celebration after a successful upgrade on a paywall, or a looping hero into your onboarding.
Replace the force-try before shipping
Lottie3View uses try! to load the dotLottie file — fine for a known bundled demo asset, but a crash waiting to happen if the file is ever missing or renamed. For animations whose names come from anywhere dynamic, decode with do/catch, log the failure with Logger, and fall back to the placeholder instead of trapping. SwiftLint already flags the force_try on that line.
Respect Reduce Motion
Heavy looping animations can be uncomfortable for users with motion sensitivity. Gate ambient loops behind the environment's accessibility setting and show a static frame (or nothing) when it's on:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
The Dex Scanner follows exactly this pattern for its scanning sweep — only animating when Reduce Motion is off.