Shotlist guide
Writing scenes
SwiftUIScene, UIKitScene, LinkScene and TCAScene in depth, with the scene context, prepare, isolation, postures, taking the root to a screen, and SwiftData.
Writing scenes
A scene is one screenshot’s worth of your app: its real root view or view controller, taken to one screen and filled with fixture content. Scenes are Swift, in your app target, inside #if DEBUG. Shotlist builds each one for every slot, locale and appearance in the run.
Because a scene is your real root, your real navigation, layout, formatting and localisation run. Only the data is fixed.
The scenes file
shotlist init writes a Screenshots.swift to start from. A complete one looks like this:
#if DEBUG
import ShotlistKit
import SwiftData
import SwiftUI
/// The App Store screenshots, as code: the real RootView, with the router set to a tab and
/// path, over an in-memory store seeded relative to the run's fixed date.
@MainActor
enum Screenshots {
static var scenes: [any ShotlistScene] {
[
SwiftUIScene("reading") { context in
RootView(router: Router(tab: .reading), clock: .fixed(context.now))
.modelContainer(try container(context))
},
SwiftUIScene("library") { context in
RootView(router: Router(tab: .library), clock: .fixed(context.now))
.modelContainer(try container(context))
},
SwiftUIScene("book") { context in
RootView(router: Router(tab: .reading, readingPath: [.book(slug: "salt-and-silver")]), clock: .fixed(context.now))
.modelContainer(try container(context))
},
LinkScene("book-link", url: "shotlist-observable://books/quiet-engines"),
]
}
static func container(_ context: ShotlistContext) throws -> ModelContainer {
try context.inMemoryContainer(for: Book.self, Meeting.self) { modelContext in
SampleShelf.seed(into: modelContext, now: context.now, locale: context.locale)
}
}
}
#endif
Three things matter here:
@MainActor, because scenes build views.- A computed
static var, not astatic let. In Swift 6, a stored static whose initialiser builds views produces a warning about view conformances. A computed property avoids it. #if DEBUGaround the whole file, so none of it reaches a Release build.
Scene ids
Each scene has an id, the first argument of every builder. Ids:
- must be unique. Two scenes with one id stop the app at launch with “Two Shotlist scenes share the id ‘…’”;
- appear in file names:
raw/en-GB/iphone-6.3/02-library-light.png; - form caption keys:
scene.library.caption; - are what
sequenceinshotlist.jsonlists, in product-page order.
A scene in the file but not in sequence is never captured. That’s useful for scenes you only run now and then, or with a second shotlist.json. A scene in sequence but not in the file fails with “the app has no scene with this id; it has …” and lists the ids it does have.
The builders
| Builder | Product | Use it for |
|---|---|---|
SwiftUIScene(id) { context in … } |
ShotlistKit | Any SwiftUI root, any architecture. |
UIKitScene(id) { context in … } |
ShotlistKit | A UIKit view controller hierarchy. |
LinkScene(id, url:) |
ShotlistKit | Starting from the host’s root, then letting the app’s own deep-link handling navigate. |
TCAScene(id, state:, reducer:, view:) |
ShotlistTCA | A TCA app: the whole screen is one State value, and the store gets pinned dependencies. |
SwiftUIScene, UIKitScene and LinkScene all take:
| Parameter | Default | Meaning |
|---|---|---|
postures: |
[.closed, .open] |
Which iPhone Duo postures the scene runs in. See Postures. |
isolation: |
.shared (.relaunch for LinkScene) |
Whether the app is relaunched before this scene. See Isolation. |
prepare: |
none | Async work before the scene is built, such as seeding a store. Readiness waits for it. See Prepare. |
TCAScene takes postures: and isolation:, but not prepare:.
The scene context
Every builder hands you a ShotlistContext:
| Property | Type | Example | Notes |
|---|---|---|---|
sceneID |
String |
"library" |
The scene being built. |
now |
Date |
14 June 2026, 10:30 UTC | The run’s fixed date, from fixedDate in shotlist.json. Route “now” through this. |
locale |
Locale |
Locale(identifier: "en-GB") |
The run’s locale. Use context.locale.identifier for the string. |
appearance |
ShotlistContext.Appearance |
.light or .dark |
The run’s appearance. Its rawValue is "light" or "dark". |
slot |
String |
"iphone-6.3" |
The slot being captured. See Slots. |
posture |
Posture |
.closed or .open |
.open only on the iPhone Duo’s inner display. |
The app is already running in the context’s locale and appearance: Shotlist launches it with that language and region, and sets the simulator’s appearance. You don’t need to apply them yourself. Use the context when your own code needs them, for fixture text in the right language, say, or a relative date.
Outside a scene builder, Shotlist.now gives the same fixed date. That’s useful for the host’s root, which link scenes start from.
SwiftUIScene
The closure builds a view. It’s a @ViewBuilder, and it may throw: a thrown error fails that cell with the error’s description.
SwiftUIScene("product") { context in
RootView(router: Router(tab: .shop, shopPath: [.product(id: "enamel-kettle")]), clock: .fixed(context.now))
}
Give the view everything it would get from your app’s entry point: its model container, environment objects, @Observable models and so on. A scene doesn’t inherit the host root’s environment.
UIKitScene
The closure returns a view controller, usually your real root, set up for the screen:
UIKitScene("departures") { context in
RootTabBarController(clock: .fixed(context.now))
},
UIKitScene("service") { context in
let root = RootTabBarController(clock: .fixed(context.now))
let board = Timetable().departures(from: RootTabBarController.homeStation, after: context.now)
if let service = board.first(where: { $0.destination == .edinburgh }) {
root.showService(service)
}
return root
},
Set up navigation inside the closure, before the controller is on screen: build the navigation stack with setViewControllers(_:animated: false), or call your own routing method as above. Shotlist turns off UIView animations for the whole capture, so nothing animates into place.
In a UIKit app, the host’s root (which link scenes start from) is a view controller; in a SwiftUI app, a UIKitScene is shown inside the SwiftUI host. Both kinds of scene work under either host.
LinkScene
A link scene starts from the root you gave the host, then asks the app to open a URL. Your own onOpenURL or scene(_:openURLContexts:) does the navigation. If your app already handles deep links, a link scene needs no change to the app at all.
// Through the app's own .onOpenURL, from its real root.
LinkScene("product-link", url: "shotlist-http://products/copper-saucepan"),
How it works:
- Shotlist shows the host’s root (a fresh copy for each link scene).
- When the root has appeared, ShotlistKit calls
UIApplication.shared.open(_:)from inside the app. - The app’s handler navigates, and readiness waits for the result as for any scene.
Opened from inside the app, a custom scheme raises no system “Open in…?” alert. The engine never uses simctl openurl, which does raise one.
Requirements:
- The URL scheme must be registered in the app’s Info.plist. If iOS doesn’t accept the URL, the scene fails with “the app didn’t accept … ; check its URL scheme”.
- UIKit apps need
scene(_:openURLContexts:), not onlyconnectionOptions.urlContexts. See Setting up a project. - The
url:string must be a valid URL.
A link scene relaunches the app by default (isolation: .relaunch), so it starts from the app’s real launch state rather than wherever the previous scene left it.
A URL that depends on the context
The second form takes a closure that builds the URL:
LinkScene("store") { context in
URL(string: "myapp://store/\(context.locale.identifier)")!
}
Seeding data for a link scene
The host’s root has no scene context of its own. To give it fixture data, seed it in the link scene’s prepare, then have the root read what was seeded:
// Through the app's own .onOpenURL. The root has no context of its own, so the scene's
// prepare seeds the store the root then reads.
LinkScene("book-link", url: "shotlist-observable://books/quiet-engines", prepare: { context in
linkContainer = try container(context)
}),
/// The store link scenes' root reads. Empty and in memory until a link scene seeds it, so a
/// capture run never opens the app's real store.
private static var linkContainer: ModelContainer = {
do {
return try ModelContainer(for: Book.self, Meeting.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true))
} catch {
fatalError("Couldn't create an in-memory store: \(error)")
}
}()
static func linkRoot() -> some View {
RootView(clock: .fixed(Shotlist.now))
.modelContainer(linkContainer)
}
And in the App:
ShotlistHost(scenes: Screenshots.scenes) { Screenshots.linkRoot() }
prepare runs before the root is built, so the root reads the seeded store.
TCAScene
From the ShotlistTCA product. The whole screen is one State value. The store is built with Shotlist’s pinned dependencies: the run’s date, incrementing UUIDs, a clock that never waits, and the scene’s locale. The state is built with them too, so an initialiser that reads @Dependency(\.uuid) or @Dependency(\.date) gives the same values every run.
#if DEBUG
import ComposableArchitecture
import ShotlistKit
import ShotlistTCA
@MainActor
enum Screenshots {
static var scenes: [any ShotlistScene] {
[
TCAScene(
"table",
state: AppFeature.State(tab: .table),
reducer: { AppFeature() },
view: { AppView(store: $0) }
),
TCAScene(
"player",
state: AppFeature.State(
tab: .players,
path: StackState([.player(PlayerDetailFeature.State(player: League.demo.player("maya-okafor")!))])
),
reducer: { AppFeature() },
view: { AppView(store: $0) }
),
// The app's own deep-link handling does the navigation, from its real root.
LinkScene("player-link", url: "shotlist-tca://players/tom-achterberg"),
]
}
}
#endif
state: is an autoclosure. When the state depends on the context, pass a closure instead:
TCAScene(
"standings",
state: { context in AppFeature.State(tab: .table, region: context.locale.region?.identifier ?? "GB") },
reducer: { AppFeature() },
view: { AppView(store: $0) }
)
Effects run as they would in the app. If the reducer loads data when the view appears, readiness waits for it: requests through fixtures are tracked, and for anything else use Shotlist.waitUntil on the store’s state.
TCAScene has no prepare:. Seed what the state needs in the state itself, or use a SwiftUIScene that builds the store.
Prepare
prepare: runs on the main actor before the scene is built. It’s for async setup: seeding a store, writing a file the screen reads, or warming a cache. Readiness waits for it to finish.
SwiftUIScene("inbox", prepare: { context in
try await InboxStore.shared.seed(Fixtures.messages(now: context.now))
}) { context in
RootView(router: Router(tab: .inbox))
}
prepare, building the scene and settling all share the scene’s timeout (capture.sceneTimeout, 20 s by default). Apreparethat takes too long fails the scene.- An error thrown from
preparefails that cell with the error’s description. - Requests and holds that start during
preparecount towards readiness as usual.
Isolation
Shotlist launches the app once per simulator, locale and appearance, and shows that batch’s scenes one after another in sequence order. Scenes in a launch share the process: its singletons, UserDefaults, files, image caches and anything else global.
| Isolation | Behaviour | Use it for |
|---|---|---|
.shared |
The scene uses the running app. The default for every builder except LinkScene. |
Scenes that build their own root from fixture data. |
.relaunch |
The app is relaunched before this scene, unless it’s the first scene of the launch. The default for LinkScene. |
Scenes that change global state, read UserDefaults written by an earlier scene, or must start from the launch state. |
SwiftUIScene("onboarding", isolation: .relaunch) { context in
OnboardingView()
}
Relaunching costs a few seconds per scene, so keep it for scenes that need it.
At the start of each run Shotlist uninstalls and reinstalls the app on each of its simulators, so every run starts with an empty app container. Data the app writes during a run stays until the next run, across launches.
Postures
Postures matter only for the iPhone Duo:
| Posture | Means |
|---|---|
.closed |
Any single-display device (iPhone, iPad), and the Duo folded: its outer display. |
.open |
The Duo unfolded: its inner display. |
By default a scene runs in both. To keep a scene to one display, list one posture:
// Only on the inner display: a two-pane layout that needs the room.
SwiftUIScene("planner", postures: [.open]) { context in
PlannerView(layout: .twoPane)
}
A scene that doesn’t list a slot’s posture is skipped there: its cell shows as “Not used here” in the Mac app and not applicable in the CLI’s summary, and it takes no place in that slot’s composed set. postures: [.open] therefore skips every iPhone and iPad slot as well, because they’re all .closed.
To adapt one scene to the display instead, read context.posture or context.slot:
SwiftUIScene("library") { context in
RootView(router: Router(tab: .library, showsSidebar: context.posture == .open))
}
See iPhone Duo.
Taking the root to a screen
Set state; don’t simulate taps. Shotlist has no taps or gestures. Every screen is reached by building the root in the right state. Common cases:
| To show | Set |
|---|---|
| A tab | The TabView(selection:) binding, through an initialiser or a router. |
| A pushed screen | The NavigationStack(path:) array, or a UINavigationController’s view controllers. |
| A sheet or full-screen cover | The optional item or Boolean that drives .sheet(item:), in the model the root reads. |
| A scrolled list | A ScrollPosition or a scroll target id the view reads, or scrollToItem(at:at:animated:) with animated: false. |
| A search in progress | The text bound to .searchable(text:). The keyboard is dismissed before capture, and the caret is hidden. |
| A selected row or segment | The selection binding. |
If a screen is only reachable through a gesture today, give its state an entry point. The same entry point makes the screen previewable and testable.
SwiftData
context.inMemoryContainer(for:seed:) builds an in-memory SwiftData container, seeds it and saves it. The app’s real queries and views then run over known content, and the developer’s own store is never opened.
static func container(_ context: ShotlistContext) throws -> ModelContainer {
try context.inMemoryContainer(for: Book.self, Meeting.self) { modelContext in
SampleShelf.seed(into: modelContext, now: context.now, locale: context.locale)
}
}
Seed with fixed identifiers and with dates relative to context.now, so relative dates (“in 4 days”) and sort order come out the same every run. See Making runs identical.
For Core Data, point your persistent store at an in-memory store in the same way, and seed it in prepare or in the builder.
swift-dependencies
Add the ShotlistDependencies product. To pin dependencies for every scene, wrap the scenes:
ShotlistHost(scenes: Screenshots.scenes.pinningDependencies()) { RootView() }
Each scene’s prepare and builder then run inside withDependencies, with:
| Dependency | Pinned to |
|---|---|
\.date |
.constant(context.now) |
\.uuid |
.incrementing |
\.continuousClock |
ImmediateClock() |
\.locale |
context.locale |
To pin them yourself, for a store you build in a scene or the host’s root, call context.pin(&$0):
SwiftUIScene("dashboard") { context in
let model = withDependencies { context.pin(&$0) } operation: { DashboardModel() }
DashboardView(model: model)
}
TCAScene pins its store for you.
Services you can’t fixture
HTTP through URLSession is answered from fixtures (see Readiness and fixtures). For everything else:
- CloudKit, HealthKit, StoreKit, location and similar system services: put them behind a protocol your views depend on, and give the scene a fixture implementation.
- Firebase: point the app at the Firebase Emulator Suite, seeded with fixture data. See Readiness and fixtures.
- Permissions alerts: don’t ask for permission in a scene. Inject a model that reports the permission as already granted.
Writing your own scene type
The builders cover most apps. If you need another, conform to ShotlistScene:
@MainActor
public protocol ShotlistScene {
var info: SceneInfo { get }
func prepare(_ context: ShotlistContext) async throws
func content(_ context: ShotlistContext) throws -> SceneContent
}
content returns .swiftUI(AnyView), .uiKit(UIViewController) or .root(URL) (the host’s root, then the URL). SceneInfo(id:kind:postures:isolation:) carries the id, a descriptive kind, postures and isolation. prepare has a default implementation that does nothing.