Scaffolding

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.

View raw AGENTS.md

Source on GitHub. See also the library overview and the API reference.

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:

Detail · child coordinator instead of nested stack
// ❌ 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 flowcoordinator.route(to: .screen(args:))
Pop the current screencoordinator.pop()
Pop everything above the rootcoordinator.popToRoot()
Show a confirmation dialogSwiftUI's .alert / .confirmationDialog
Show a one-screen sheet (simple form, info)SwiftUI's .sheet(item:)
Show a multi-step sub-flowcoordinator.present(.subflow, as: .sheet)
Show a full-screen sub-flowcoordinator.present(.subflow, as: .fullScreenCover)
Atomically replace the entire view hierarchy (auth, onboarding)appCoordinator.setRoot(.authenticated) (on a RootCoordinatable)
Switch tabs programmaticallytabCoordinator.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

HomeCoordinator.swift
@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 typeWhat it generates
some ViewA view destination
any CoordinatableA 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.

Concrete vs. existential
// ❌ 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:

Redundant @ScaffoldingIgnored
// ❌ 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 tracked

Use 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 on customize
@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.

ProtocolUse when
FlowCoordinatablePush/pop navigation. The workhorse. Wraps a NavigationStack.
TabCoordinatableTab bar where each tab is independent. Each tab's content is its own coordinator.
RootCoordinatableAtomic 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 = false for 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 @Environment and calls coordinator.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:

Cross-module composition
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 · presenter
// 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:

LoginCoordinator · presented
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

HomeCoordinator with present(.settings, as: .sheet)
@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 + native .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

AppCoordinator · RootCoordinatable
@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

MainTabCoordinator
@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"))
    }
}

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.
Won't compile
// ❌ 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
}
Two patterns that do work
// ✅ 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.meta showing 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:

AdaptiveTopBar.swift
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

01

Wrapping a destination view in NavigationStack

Anti-pattern · nested 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.

02

Blanket @ScaffoldingIgnored on non-route members

Anti-pattern · redundant annotations
// ❌ 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 tracked

The 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.

03

Holding navigation state in a view

Anti-pattern · view-owned state
// ❌ 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.

04

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.

Anti-pattern · old combined API
// ❌ Old, no longer exists.
coordinator.route(to: .settings, as: .sheet)

// ✅ Correct.
coordinator.present(.settings, as: .sheet)
05

Reaching for NavigationLink to push

Anti-pattern · NavigationLink coupling
// ❌ 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, ...)
}
06

Calling dismissCoordinator() to close a single screen

Anti-pattern · misusing dismissCoordinator
// ❌ 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's swift-tools-version is 6.2.
  • Platform floor: iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / macCatalyst 18. TabRole is available unconditionally on this floor.
  • onDismiss and 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 (or NavigationView, NavigationSplitView) inside a flow's view tree.

TL;DR for code generation

When asked to add navigation to a Scaffolding project:

  1. Don't generate NavigationStack, NavigationView, or NavigationSplitView anywhere inside a FlowCoordinatable's view hierarchy.
  2. Decide push vs. modal vs. root-swap, then pick route(to:) / present(_:as:) / setRoot(_:).
  3. For modals, decide view-only vs. sub-flow:
    • View-only → SwiftUI native .sheet(item:).
    • Sub-flow → present(_:as:) with a child coordinator.
  4. 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.
  5. Views read the coordinator from @Environment(MyCoordinator.self) and call methods on it. Views never store path or sheet booleans for flow-driven navigation.
  6. Cross-coordinator results are delivered by the presenter installing an onComplete callback at construction time; the presented coordinator calls the callback then dismissCoordinator().

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.