Agent guide · Best practices
Scaffolding for agents.
A reference for LLM coding agents (and humans) writing SwiftUI code with the Scaffolding library. Read this before generating navigation code in any project that uses Scaffolding.
Why Scaffolding exists
SwiftUI's NavigationStack(path:) works for a single,
self-contained screen graph. It breaks down once an app has:
- multiple feature modules that need to push into each other,
- destination types defined in different modules,
- coordinator-driven flows (login, onboarding, settings sheets),
- programmatic navigation that has to compose across module boundaries.
NavigationStack keeps navigation inside the view tree. That's the design
constraint Scaffolding is built to escape. A FlowCoordinatable is a NavigationStack — but its destinations live on the
coordinator (a plain Swift class), the macro generates the
destination enum, and child coordinators slot in as routes
without the view tree knowing.
If you find yourself reaching for NavigationStack(path:) inside a Scaffolding project, stop. There is almost certainly a
coordinator-side answer.
The hard rule: do not nest NavigationStack
FlowCoordinatable already wraps a NavigationStack internally. SwiftUI does not compose NavigationStacks with
each other — the inner stack swallows pushes that should belong
to the outer one, and route(to:) stops doing what
you expect.
If a screen needs its own navigation hierarchy, give it a child coordinator:
// ❌ Wrong — nested NavigationStack breaks routing.
func detail(item: Item) -> some View {
NavigationStack { // ← don't.
DetailRoot(item: item)
}
}
// ✅ Right — child FlowCoordinator gets its own NavigationStack at the
// coordinator boundary, where SwiftUI handles it correctly.
func detail(item: Item) -> any Coordinatable {
DetailCoordinator(item: item)
}Picking a navigation primitive
When a user-facing transition needs to happen, use this decision tree:
Is it a push/pop on the current stack?
├─ Yes → coordinator.route(to: .someDestination)
│
└─ No, it's a modal.
│
Is the modal a single screen — confirmation, info dialog,
simple form, picker?
│
├─ Yes → SwiftUI native: .sheet(item:) / .fullScreenCover(item:)
│
└─ No, the modal contains its own navigation flow
(multiple steps, push, dismiss-with-result, etc.).
│
→ coordinator.present(.flow, as: .sheet)
(returns a child coordinator from the route function) Concretely
| You want… | Use |
|---|---|
| Push a screen onto the current flow | coordinator.route(to: .screen(args:)) |
| Pop the current screen | coordinator.pop() |
| Pop everything above the root | coordinator.popToRoot() |
| Show a confirmation dialog | SwiftUI's .alert / .confirmationDialog |
| Show a one-screen sheet (simple form, info) | SwiftUI's .sheet(item:) |
| Show a multi-step sub-flow | coordinator.present(.subflow, as: .sheet) |
| Show a full-screen sub-flow | coordinator.present(.subflow, as: .fullScreenCover) |
| Atomically replace the entire view hierarchy (auth, onboarding) | appCoordinator.setRoot(.authenticated) (on a RootCoordinatable) |
| Switch tabs programmatically | tabCoordinator.selectFirstTab(.home) |
Stay native for view-only modals. The native modifier is
lighter, requires no coordinator boundary, and avoids the
overhead of an extra Destinations case.
Coordinator anatomy
@MainActor @Observable @Scaffoldable
final class HomeCoordinator: @MainActor FlowCoordinatable {
// Required: the observable container that owns the stack.
var stack = FlowStack<HomeCoordinator>(root: .home)
// Routes — each becomes a `Destinations` enum case.
func home() -> some View { HomeView() }
func detail(item: Item) -> some View { DetailView(item: item) }
func settings() -> any Coordinatable { SettingsCoordinator() }
// Optional helpers. Void return type ⇒ never tracked by the macro —
// no @ScaffoldingIgnored needed (or wanted) here.
func openDetail(_ item: Item) {
route(to: .detail(item: item))
}
}Auto-tracked return types
The @Scaffoldable macro scans the coordinator's functions — and only functions; properties, init, and deinit are never scanned —
and generates a Destinations enum case for every
function whose return type is one of:
| Return type | What it generates |
|---|---|
some View | A view destination |
any Coordinatable | A child-coordinator destination |
(any Coordinatable, some View) | Tab: coordinator + label view |
(some View, some View) | Tab: view-only + label view |
(any Coordinatable, TabRole) | Tab: coordinator + role |
(some View, TabRole) | Tab: view-only + role |
(any Coordinatable, some View, TabRole) | Tab: coordinator + label + role |
(some View, some View, TabRole) | Tab: view-only + label + role |
Anything else is skipped automatically: Void functions, concrete return types — including
a concrete coordinator type like -> LoginCoordinator — closures, generic types,
arrays, and any tuple shape not in the table. None of it needs
an annotation. For a child coordinator the return type must be any Coordinatable (the
existential); views must return some View.
// ❌ Won't be picked up — concrete type.
func login() -> LoginCoordinator { LoginCoordinator() }
// ✅ Existential — macro generates a `.login` case.
func login() -> any Coordinatable { LoginCoordinator() }Marking exclusions
Don't put @ScaffoldingIgnored on
everything that isn't a route. Properties, Void helpers, and unsupported return types are never tracked — the
attribute is redundant noise there:
// ❌ All redundant — none of these is tracked in the first place.
@ScaffoldingIgnored var session: AuthToken? // properties: never scanned
@ScaffoldingIgnored func openDetail(_ item: Item) { // returns Void: never tracked
route(to: .detail(item: item))
}
@ScaffoldingIgnored func makeHandler() -> () -> Void { ... } // closure return: never trackedUse it only when a method returns one of the
auto-tracked types but isn't a destination —
typically a customize(_:) override, a helper view
builder shared between screens, or a non-route coordinator
factory:
@ScaffoldingIgnored
func customize(_ view: AnyView) -> some View {
view
.navigationBarTitleDisplayMode(.inline)
.toolbar { /* shared toolbar */ }
}There is no opt-in tracking attribute. Auto-tracking by return
type plus exclusion via @ScaffoldingIgnored is the
only mechanism.
Three coordinator protocols
Pick by user-facing structure, not by mood.
| Protocol | Use when |
|---|---|
FlowCoordinatable | Push/pop navigation. The workhorse. Wraps a NavigationStack. |
TabCoordinatable | Tab bar where each tab is independent. Each tab's content is its own coordinator. |
RootCoordinatable | Atomic root swap: auth flow ↔ main app, onboarding ↔ home. The whole tree is replaced when setRoot(_:) is called. |
A typical app uses all three:
AppCoordinator (Root)
├── LoginCoordinator (Flow) ← unauthenticated
└── MainTabCoordinator (Tab) ← authenticated
├── HomeCoordinator (Flow)
│ └── DetailView (push) + SettingsCoordinator (modal)
└── ProfileCoordinator (Flow)
└── EditProfileView (push) setRoot flips between the two App children. The tab coordinator owns the home/profile flows. Each
flow handles its own pushes and modals.
Separation of concerns — the discipline
This is the part that makes Scaffolding worth using. If you violate it, you've reintroduced the problems Scaffolding was built to solve.
Views never own navigation state
- Don't A view holds
@State path: [SomeType]. - Don't A view holds
@State isPresented = falsefor a sheet that's part of the flow. - Don't A view receives
@Binding path: [SomeType]to pop. - Do A view reads its coordinator from
@Environmentand callscoordinator.route(to:),coordinator.pop(),coordinator.present(_:as:)etc.
Coordinators don't know how their views render
- Don't A coordinator imports SwiftUI just to construct a
NavigationStack. - Don't A coordinator reads
@Environment(it's not a View). - Do A coordinator's job is route declaration + state mutation. The macro and the framework wire the views to the stack.
Modules expose coordinators, not views
In a multi-module app, the right unit of import is the coordinator type:
import HomeFeature
let home = HomeCoordinator()
appRoot.setRoot(.home(home))Other modules don't need to know what views are inside, what destinations exist, or how the flow is structured. They hold a coordinator reference and route to its surface.
Result delivery between coordinators
When a presented coordinator needs to return a value, take the callback in the route function:
// AppCoordinator
func login(onComplete: @escaping @MainActor (AuthToken) -> Void) -> any Coordinatable {
LoginCoordinator(onComplete: onComplete)
}
func startLogin() {
present(.login(onComplete: { [weak self] token in
self?.session = token
}), as: .sheet)
}Inside LoginCoordinator, when the user finishes:
func submit() {
onComplete(AuthToken(...)) // deliver result
dismissCoordinator() // dismiss self
}The presenter doesn't observe the presented coordinator's state. The presented coordinator hands a result back through the closure it was constructed with, then dismisses itself. Clean boundaries.
dismissCoordinator() semantics
dismissCoordinator() is called on the coordinator
being removed. It pops the whole coordinator off its parent — not a screen. For a sheet/cover, that closes
the modal. For a pushed child coordinator, that removes the
child and any of its own pushed destinations.
To pop a single screen within the same flow, use pop(). The two are not interchangeable.
Quick patterns
A flow with a sheet that's a sub-flow
@MainActor @Observable @Scaffoldable
final class HomeCoordinator: @MainActor FlowCoordinatable {
var stack = FlowStack<HomeCoordinator>(root: .home)
func home() -> some View { HomeView() }
func detail(item: Item) -> some View { DetailView(item: item) }
func settings() -> any Coordinatable { SettingsCoordinator() }
func openSettings() {
present(.settings, as: .sheet)
}
}A flow with a one-screen view-only sheet
// Coordinator: no `.confirmation` route — that's an internal view detail.
@MainActor @Observable @Scaffoldable
final class HomeCoordinator: @MainActor FlowCoordinatable {
var stack = FlowStack<HomeCoordinator>(root: .home)
func home() -> some View { HomeView() }
}
// View: native sheet + local `@State`. The confirmation isn't a flow,
// it's a single screen — keep it native.
struct HomeView: View {
@Environment(HomeCoordinator.self) private var coordinator
@State private var pendingDelete: Item?
var body: some View {
List(items) { item in
Button(item.name) { pendingDelete = item }
}
.sheet(item: $pendingDelete) { item in
ConfirmDeleteSheet(item: item) {
/* perform delete */
}
}
}
}Atomic auth swap
@MainActor @Observable @Scaffoldable
final class AppCoordinator: @MainActor RootCoordinatable {
var root = Root<AppCoordinator>(root: .unauthenticated)
func unauthenticated() -> any Coordinatable { LoginCoordinator() }
func authenticated() -> any Coordinatable { MainTabCoordinator() }
func signIn() { setRoot(.authenticated) }
func signOut() { setRoot(.unauthenticated) }
}Tab bar with independent flows
@MainActor @Observable @Scaffoldable
final class MainTabCoordinator: @MainActor TabCoordinatable {
var tabItems = TabItems<MainTabCoordinator>(tabs: [.home, .profile])
func home() -> (any Coordinatable, some View) {
(HomeCoordinator(), Label("Home", systemImage: "house"))
}
func profile() -> (any Coordinatable, some View) {
(ProfileCoordinator(), Label("Profile", systemImage: "person"))
}
}Deep linking
Every navigation method that resolves a child coordinator
(route, present, setRoot, appendTab, insertTab, popToFirst, popToLast, selectFirstTab, selectLastTab, select(index:), select(id:)) ships
an overload constrained to <T: Coordinatable> with a trailing closure. The closure fires after the route
lands, receiving a typed reference to the freshly-resolved
child — chain them to walk the tree from a cold launch.
@Scaffoldable @Observable
final class AppCoordinator: @MainActor RootCoordinatable {
var root = Root<AppCoordinator>(root: .unauthenticated)
func unauthenticated() -> any Coordinatable { LoginCoordinator() }
func authenticated() -> any Coordinatable { MainTabCoordinator() }
/// Land on the user's profile from a URL / push / quick action.
/// Each `<T: Coordinatable>` overload hands the next step a typed
/// reference to the freshly-resolved child.
func openProfile(userId: Int) {
setRoot(.authenticated) { (tab: MainTabCoordinator) in
tab.selectFirstTab(.profile) { (profile: ProfileCoordinator) in
profile.route(to: .userDetail(id: userId))
}
}
}
}WindowGroup {
coordinator.view
.onOpenURL { url in
if let userId = parseUserURL(url) {
coordinator.openProfile(userId: userId)
}
}
}- Do Pick the concrete coordinator type that matches the
route's return signature for
T. The closure only fires if the cast succeeds. - Don't Store handles to child coordinators outside the chain. The typed overloads exist so you don't have to — they hand you the right reference at the right time.
- Don't Deep-link in pieces from a view. Deep-linking belongs on
the coordinator (or on whatever orchestrator owns the
URL/push entry point), and views call into it. A view
that dispatches multiple
route(to:)/setRoot(_:)calls in sequence is a smell.
Previews
SwiftUI's #Preview and Scaffolding's @Scaffoldable are both compile-time macros, but
they don't compose at runtime the way you might expect. When
generating preview code in a Scaffolding project, follow these
rules — they save the user from chasing phantom bugs.
- Don't Generate
HomeCoordinator(initialRoute: .detail). The macro doesn't emit an init that takes a starting destination — there's nothing to call. - Do Preview the coordinator at its real root
(
HomeCoordinator().view), or render the leaf view directly and inject the coordinator it reads.
// ❌ The macro doesn't synthesise an initial-route initialiser.
// There's no `init(initialRoute:)` to seed a non-default starting
// case from a preview.
#Preview {
HomeCoordinator(initialRoute: .detail(item: planet)).view
}// ✅ Preview the coordinator at its actual root...
#Preview("Coordinator · root") {
HomeCoordinator().view
}
// ✅ ...or render the leaf view directly and inject what it reads
// from the environment.
#Preview("DetailView · pushed") {
DetailView(item: .earth)
.environment(HomeCoordinator()) // satisfies @Environment(HomeCoordinator.self)
}- Do For any view that declares
@Environment(SomeCoordinator.self), attach.environment(SomeCoordinator())in the preview. Without it, the lookup falls back to a default (or crashes on Swift 6). - Don't Make rendering decisions that depend on
destination.routeType/destination.presentationType/destination.metashowing the runtime value in previews. The destination env value is set when Scaffolding materialises a route — a preview that renders a leaf view gets the default (.root), regardless of how the screen would be reached at runtime.
Adaptive bars from \.destination
The flip side of caveat 3: at runtime the destination
environment is reliable, and its public properties are exactly
what you need to write a single reusable chrome that adapts to
push / sheet / cover / root context. The pattern below is the
canonical use of destination.routeType:
import SwiftUI
import Scaffolding
/// Reusable top bar that adapts to how the current screen was reached.
/// Reads the routing metadata Scaffolding injects automatically into
/// every materialised destination via `\.destination`.
struct AdaptiveTopBar: View {
let title: String
@Environment(\.destination) private var destination
@Environment(\.dismiss) private var dismiss
var body: some View {
HStack {
switch destination.routeType {
case .push:
Button { dismiss() } label: { Image(systemName: "chevron.left") }
case .sheet, .fullScreenCover:
Button("Close") { dismiss() }
case .root:
Color.clear.frame(width: 24)
}
Spacer()
Text(title).font(.headline)
Spacer()
Color.clear.frame(width: 24, height: 1)
}
.padding(.horizontal, 16)
.frame(height: 44)
}
}Switch on destination.meta when the same view
renders different layouts depending on which generated case
led to it. The Meta enum is emitted alongside Destinations by the macro.
Common mistakes — what NOT to generate
Wrapping a destination view in NavigationStack
// ❌ Breaks `route(to:)` from the parent flow.
func detail(item: Item) -> some View {
NavigationStack {
DetailScreen(item: item)
}
}Drop the NavigationStack. The parent flow already
provides one.
Blanket @ScaffoldingIgnored on non-route members
// ❌ All redundant — none of these is tracked in the first place.
@ScaffoldingIgnored var session: AuthToken? // properties: never scanned
@ScaffoldingIgnored func openDetail(_ item: Item) { // returns Void: never tracked
route(to: .detail(item: item))
}
@ScaffoldingIgnored func makeHandler() -> () -> Void { ... } // closure return: never trackedThe macro only considers functions whose return type is in the
auto-tracked table. Properties, Void methods,
concrete types, closures, and generics are ignored
automatically — reserve @ScaffoldingIgnored for the cases that genuinely
need it.
Holding navigation state in a view
// ❌ Defeats the point of coordinators.
struct HomeView: View {
@State private var pushedDetail: Item?
@State private var showSettings = false
var body: some View {
NavigationStack {
List(...)
.navigationDestination(item: $pushedDetail) { ... }
.sheet(isPresented: $showSettings) { ... }
}
}
}Move pushes to the coordinator
(coordinator.route(to: .detail(item:))). Keep the
sheet only if it's a true single-screen view-only modal.
route(to:as:) (old API)
That API was split. Push uses route(to:). Modals
use present(_:as:). There is no as: parameter on route anymore.
// ❌ Old, no longer exists.
coordinator.route(to: .settings, as: .sheet)
// ✅ Correct.
coordinator.present(.settings, as: .sheet)Reaching for NavigationLink to push
// ❌ Couples the row to navigation; breaks under modular coordinators.
NavigationLink(value: planet) { Label(planet.name, ...) }
// ✅ Plain Button + coordinator call.
Button {
coordinator.route(to: .detail(item: planet))
} label: {
Label(planet.name, ...)
}Calling dismissCoordinator() to close a single screen
// ❌ Dismisses the entire coordinator, not just the current screen.
struct DetailView: View {
@Environment(HomeCoordinator.self) private var coordinator
var body: some View {
Button("Back") { coordinator.dismissCoordinator() }
}
}Use coordinator.pop() — or SwiftUI's @Environment(\.dismiss), which works because
Scaffolding wraps NavigationStack. Save dismissCoordinator() for "close the whole
sub-flow" cases.
Compatibility notes
- Scaffolding requires Swift 6.2 (
@Observable, the macro toolchain, strict concurrency). The package'sswift-tools-versionis6.2. - Platform floor: iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / macCatalyst 18.
TabRoleis available unconditionally on this floor. onDismissand the deep-link trailing closures are typed@MainActor () -> Void/@MainActor (T) -> Void. Annotate any closures you forward.- Scaffolding plays well with SwiftUI's
@Environment(\.dismiss),@Environment(\.scenePhase),@Environment(\.openURL), etc. — those are native environment values that don't conflict with the coordinator injection. - Scaffolding does conflict with anything that introduces another
NavigationStack(orNavigationView,NavigationSplitView) inside a flow's view tree.
TL;DR for code generation
When asked to add navigation to a Scaffolding project:
- Don't generate
NavigationStack,NavigationView, orNavigationSplitViewanywhere inside aFlowCoordinatable's view hierarchy. - Decide push vs. modal vs. root-swap, then pick
route(to:)/present(_:as:)/setRoot(_:). - For modals, decide view-only vs. sub-flow:
- View-only → SwiftUI native
.sheet(item:). - Sub-flow →
present(_:as:)with a child coordinator.
- View-only → SwiftUI native
- New routes go on the coordinator as functions returning
some View,any Coordinatable, or a tab tuple. Add the function — the macro generates the case. - Views read the coordinator from
@Environment(MyCoordinator.self)and call methods on it. Views never store path or sheet booleans for flow-driven navigation. - Cross-coordinator results are delivered by the presenter
installing an
onCompletecallback at construction time; the presented coordinator calls the callback thendismissCoordinator().
If you can't figure out which coordinator should own a destination, the answer is usually "the closest existing one" — don't invent new coordinator types just to host one route.