Scaffolding

Documentation

Meet Scaffolding.

Learn how Scaffolding turns plain Swift functions into type-safe navigation routes — and why you might never write a destination enum again.

Companion to the hosted DocC archive. Source on GitHub.

Overview

Navigation in SwiftUI is powerful, but as apps grow it creates a familiar set of problems: navigation logic is scattered across views, destination enums must be maintained by hand, and deep linking requires plumbing that touches every layer of the app.

Scaffolding solves this by moving navigation into coordinators — observable classes whose functions are the routes. The @Scaffoldable macro reads those functions at compile time and generates a Destinations enum automatically. You navigate by calling route(to:) to push or present(_:as:) to show a modal, and Scaffolding handles the rest using SwiftUI's native navigation stack, sheets, and full-screen covers under the hood.

How it works

  1. Create a class and mark it @Scaffoldable @Observable.
  2. Conform to one of three coordinator protocols.
  3. Write functions — each one becomes a route.
  4. The macro generates a Destinations enum from those functions.
  5. Push with coordinator.route(to: .someDestination) or present a modal with coordinator.present(.someDestination, as: .sheet).

Return types that become routes

The macro decides what kind of destination to generate based on the function's return type:

Return typeWhat it createsTypical use
some ViewA view destinationSimple screens
any CoordinatableA child coordinatorNested navigation flows
(any Coordinatable, some View)Coordinator + tab labelTab bar tabs
(some View, some View)View + tab labelView-only tabs

Functions marked with @ScaffoldingIgnored or returning other types are skipped.

Mounting the coordinator

A coordinator is a plain Swift class — it doesn't render anything until you ask it to. Hold the root coordinator in SwiftUI @State at the app entry point, then read its view property to mount its full navigation hierarchy as a SwiftUI view:

MyApp.swift
import SwiftUI
import Scaffolding

@main
struct MyApp: App {
    // Hold the root coordinator in @State so SwiftUI keeps it alive
    // for the lifetime of the scene.
    @State private var coordinator = AppCoordinator()

    var body: some Scene {
        WindowGroup {
            coordinator.view              // computed property — no parens
        }
    }
}

view is a computed property, not a function — there are no parentheses. SwiftUI re-renders whenever the coordinator's @Observable state changes, so the navigation stack, presented modals, and injected child coordinators all stay in sync without any further wiring.

The same property works in #Preview — instantiate the coordinator and read .view directly:

HomeCoordinator preview
#Preview {
    HomeCoordinator().view
}

Only the root coordinator needs an explicit @State mount. Child coordinators returned from route functions (any Coordinatable) are instantiated by the parent and their views are rendered automatically when the parent routes to them.

Previewing coordinator code

SwiftUI's #Preview macro and Scaffolding's @Scaffoldable macro both run at compile time, but they don't see each other the way the runtime does. A handful of preview-only papercuts are easy to chase as bugs if you're not expecting them.

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)
}

FlowCoordinatable — Navigation stacks

FlowCoordinatable manages a push/pop navigation stack with support for sheet and full-screen-cover modals. This is the coordinator you'll use most often.

HomeCoordinator.swift
@Scaffoldable @Observable
final class HomeCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<HomeCoordinator>(root: .home)

    func home() -> some View { HomeView() }
    func detail(item: String) -> some View { DetailView(item: item) }
    func settings() -> any Coordinatable { SettingsCoordinator() }
}

Push with route(to:), present a modal with present(_:as:):

Navigation
coordinator.route(to: .detail(item: "Earth"))           // push
coordinator.present(.settings, as: .sheet)              // sheet
coordinator.present(.settings, as: .fullScreenCover)    // full-screen cover

coordinator.pop()
coordinator.popToRoot()
coordinator.popToFirst(.detail)
coordinator.popToLast(.detail)

present(_:as:) is available on every coordinator type, so a TabCoordinatable or RootCoordinatable can host a modal directly without delegating to a child flow.

Go back with pop(), popToRoot(), or pop to a specific destination with popToFirst(_:) and popToLast(_:).

TabCoordinatable — Tab bars

TabCoordinatable manages a tab bar where each tab can contain its own coordinator with an independent navigation stack. Each function returns a tuple of the tab's content and its label.

MainTabCoordinator.swift
@Scaffoldable @Observable
final class MainTabCoordinator: @MainActor TabCoordinatable {
    var tabItems = TabItems<MainTabCoordinator>(tabs: [.feed, .profile])

    func feed() -> (any Coordinatable, some View) {
        (FeedCoordinator(), Label("Feed", systemImage: "list.bullet"))
    }

    func profile() -> (any Coordinatable, some View) {
        (ProfileCoordinator(), Label("Profile", systemImage: "person"))
    }
}

Select tabs programmatically, add or remove them at runtime:

Tab API
coordinator.selectFirstTab(.feed)
coordinator.appendTab(.notifications)
coordinator.removeLastTab(.notifications)

On iOS 18+ you can also include a TabRole as a third tuple element to use the new tab bar API.

RootCoordinatable — State switches

RootCoordinatable holds a single root destination that can be swapped atomically. This is ideal for authentication flows, onboarding gates, or any state where the entire view hierarchy needs to change.

AppCoordinator.swift
@Scaffoldable @Observable
final class AppCoordinator: @MainActor RootCoordinatable {
    var root = Root<AppCoordinator>(root: .splash)

    func splash() -> some View { SplashView() }
    func authenticated()   -> any Coordinatable { MainTabCoordinator() }
    func unauthenticated() -> any Coordinatable { LoginCoordinator() }
}

One call flips the app state:

Root swap
coordinator.setRoot(.authenticated)

Environment access

Scaffolding injects every coordinator in the hierarchy into the SwiftUI environment. Views access their nearest coordinator with @Environment:

DetailView.swift
struct DetailView: View {
    @Environment(HomeCoordinator.self) private var coordinator

    var body: some View {
        Button("Next") {
            coordinator.route(to: .nextScreen)
        }
    }
}

If multiple coordinators of the same type exist in the view tree, the one closest to the current view is used.

You can also inspect how the current view was presented by reading the destination environment value:

Destination metadata
@Environment(\.destination) private var destination

// destination.routeType        → .root, .push, .sheet, or .fullScreenCover
// destination.presentationType → how this view was presented globally
// destination.meta             → which case of Destinations this is

A reusable, route-aware top bar

Because every destination Scaffolding materialises sets \.destination, a single SwiftUI view can read the routing metadata and adapt itself to context. The same bar renders a back chevron when the screen is pushed, a Close when it's presented as a sheet or full-screen cover, and nothing leading when it's the root — without the bar knowing anything about the surrounding flow.

AdaptiveTopBar.swift · reusable
import SwiftUI
import Scaffolding

/// A top bar that adapts its leading control to how the screen was
/// reached. Drop it into any view managed by a Scaffolding coordinator
/// — `\.destination` is injected automatically for every destination
/// the framework materialises.
struct AdaptiveTopBar: View {
    let title: String

    @Environment(\.destination) private var destination
    // Scaffolding wraps NavigationStack, so SwiftUI's native dismiss
    // works for both pops (push) and modal dismissals.
    @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:
                // The root has no parent control — keep the layout
                // stable with a placeholder of the same width.
                Color.clear.frame(width: 24)
            }

            Spacer()
            Text(title).font(.headline)
            Spacer()

            // Mirror the leading control to keep the title centred.
            Color.clear.frame(width: 24, height: 1)
        }
        .padding(.horizontal, 16)
        .frame(height: 44)
    }
}

// Use it from any screen — the same view shows a back chevron when
// pushed and a "Close" when presented as a sheet.
struct DetailView: View {
    let item: Planet
    var body: some View {
        VStack(spacing: 0) {
            AdaptiveTopBar(title: item.name)
            ...
        }
    }
}

The same trick generalises to any chrome that should react to routing context — toolbar items, breadcrumb labels, swipe affordances. destination.meta lets you switch on the specific destination case (the macro generates a Meta enum alongside Destinations), which is useful when a screen renders different layouts depending on which route reached it.

Composing coordinators

Coordinators nest naturally. A FlowCoordinatable can route to a child coordinator (returned as any Coordinatable), which can itself be any coordinator type. A typical app hierarchy looks like this:

AppCoordinator (Root)
├── LoginCoordinator (Flow)
└── MainTabCoordinator (Tab)
    ├── HomeCoordinator (Flow)
    │   └── DetailCoordinator (Flow)
    └── ProfileCoordinator (Flow)

A child coordinator destination can take parameters — useful for delivering a result back via a callback, the way the demo's LoginCoordinator works:

Composition with onComplete
@Scaffoldable @Observable
final class AppCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<AppCoordinator>(root: .home)

    var session: AuthToken?

    // A child Coordinatable as a route — the macro requires the return
    // type to be `any Coordinatable` (not the concrete type) to recognise
    // it as a coordinator destination. `present(.login(...))` then
    // resolves the LoginCoordinator and presents it as a sheet, with
    // the parent set automatically and the result delivered through
    // the `onComplete` callback the parent installs at construction time.
    func login(onComplete: @escaping @MainActor (AuthToken) -> Void) -> any Coordinatable {
        LoginCoordinator(onComplete: onComplete)
    }

    func startLoginFlow() {
        present(.login(onComplete: { [weak self] token in
            self?.session = token
        }), as: .sheet)
    }
}

Dismissing a flow

pop() and dismissCoordinator() are not the same call. pop() removes a single pushed screen from the current flow's stack. dismissCoordinator() removes the entire coordinator from its parent — collapsing whatever sub-tree it owns (its root, every screen it has pushed, every modal it has presented) in one move.

The difference shows up clearly in a nested flow. Below, an AppCoordinator presents a LoginCoordinator as a sheet. The login flow is itself two screens (emailpassword). When the user signs in on the password step, calling dismissCoordinator() closes the whole sheet at once — not just the password screen.

AppCoordinator.swift · presents the login flow
@Scaffoldable @Observable
final class AppCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<AppCoordinator>(root: .home)

    var session: AuthToken?

    func home() -> some View { HomeView() }

    // Child coordinator route — return type must be `any Coordinatable`
    // for the macro to recognise it as a coordinator destination.
    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)
    }
}
LoginCoordinator.swift · the nested flow
@Scaffoldable @Observable
final class LoginCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<LoginCoordinator>(root: .email)

    let onComplete: @MainActor (AuthToken) -> Void

    init(onComplete: @escaping @MainActor (AuthToken) -> Void) {
        self.onComplete = onComplete
    }

    func email()                    -> some View { EmailStepView() }
    func password(email: String)    -> some View { PasswordStepView(email: email) }
}
Step views · pop() vs dismissCoordinator()
struct EmailStepView: View {
    @Environment(LoginCoordinator.self) private var coordinator
    @State private var email = ""

    var body: some View {
        Form {
            TextField("Email", text: $email)
            Button("Next") {
                coordinator.route(to: .password(email: email))   // push
            }
        }
    }
}

struct PasswordStepView: View {
    @Environment(LoginCoordinator.self) private var coordinator
    let email: String
    @State private var password = ""

    var body: some View {
        Form {
            SecureField("Password", text: $password)
            HStack {
                Button("Back")    { coordinator.pop() }                   // pop ONE screen
                Button("Sign in") {
                    let token = signIn(email: email, password: password)
                    coordinator.onComplete(token)
                    coordinator.dismissCoordinator()                       // close the whole sheet
                }
            }
        }
    }
}

Drive the simulator on the right to see it: present the login sheet, push to the password step inside it, then compare pop() (one step back) to dismissCoordinator() (whole sheet closes, regardless of how deep you've pushed).

Customizing views

Override customize(_:) on a coordinator to apply shared modifiers to every view it manages. Mark it with @ScaffoldingIgnored so the macro doesn't treat it as a route:

Coordinator+customize
@ScaffoldingIgnored
func customize(_ view: AnyView) -> some View {
    view
        .navigationBarTitleDisplayMode(.inline)
        .toolbar { /* shared toolbar items */ }
}

Compared to NavigationStack(path:)

The same three-screen app — list, detail, settings sheet — written once with SwiftUI's NavigationStack(path:) and once with Scaffolding. The shape of the difference is the shape of the library's value.

SwiftUI NavigationStack(path:)
ContentView · view-hosted
// SwiftUI · NavigationStack(path:) · view-hosted

struct ContentView: View {
    @State private var path: [Planet] = []
    @State private var showSettings = false

    var body: some View {
        NavigationStack(path: $path) {
            List(planets) { planet in
                // The list row is itself the navigation link.
                NavigationLink(value: planet) {
                    Label(planet.name, systemImage: "globe")
                }
            }
            .navigationTitle("Planets")
            .navigationDestination(for: Planet.self) { planet in
                DetailView(item: planet, path: $path)
            }
            .toolbar {
                Button("Settings") { showSettings = true }
            }
            .sheet(isPresented: $showSettings) {
                SettingsView()
            }
        }
    }
}

struct DetailView: View {
    let item: Planet
    @Binding var path: [Planet]
    // Or, instead of holding the path binding, the view can lean on
    // SwiftUI's environment value for dismissal:
    // @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            Text(item.name).font(.title)
            Button("Back") { path.removeLast() }
            // ...equivalently: Button("Back") { dismiss() }
        }
    }
}
Scaffolding FlowCoordinatable
HomeCoordinator.swift
// Scaffolding · FlowCoordinatable

@Scaffoldable @Observable
final class HomeCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<HomeCoordinator>(root: .home)

    func home()               -> some View         { HomeView() }
    func detail(item: Planet) -> some View         { DetailView(item: item) }
    func settings()           -> any Coordinatable { SettingsCoordinator() }
}

struct HomeView: View {
    @Environment(HomeCoordinator.self) private var coordinator

    var body: some View {
        List(planets) { planet in
            Button {
                coordinator.route(to: .detail(item: planet))
            } label: {
                Label(planet.name, systemImage: "globe")
            }
        }
        .navigationTitle("Planets")
        .toolbar {
            Button("Settings") {
                coordinator.present(.settings, as: .sheet)
            }
        }
    }
}

struct DetailView: View {
    @Environment(HomeCoordinator.self) private var coordinator
    // Scaffolding uses NavigationStack underneath, so SwiftUI's native
    // environment dismiss still works alongside the coordinator API.
    @Environment(\.dismiss) private var dismiss
    let item: Planet

    var body: some View {
        VStack {
            Text(item.name).font(.title)
            Button("Back") { coordinator.pop() }
            // ...or equivalently: Button("Back") { dismiss() }
        }
    }
}

What changes

  • Path state moves out of the view. Native carries @State path and @State showSettings in ContentView; Scaffolding's coordinator owns the FlowStack, so views never store navigation state themselves.
  • Pushes are explicit calls, not NavigationLinks. The native list couples the row to navigation through NavigationLink(value:). With Scaffolding the row is a plain Button that calls coordinator.route(to: .detail(item:)) — the view stays decoupled from how navigation happens.
  • Modals don't need a binding. Native modals require a @State Bool and a .sheet(isPresented:) modifier on the view tree. Scaffolding presents from a method call — coordinator.present(.settings, as: .sheet) — and dismisses with pop().
  • Pop doesn't need a @Binding. The native DetailView takes @Binding var path so it can call removeLast(). Scaffolding's DetailView reads the coordinator from @Environment and just calls pop().
  • Type-safe destinations across modules. A heterogeneous NavigationStack path needs a custom enum and a navigationDestination per case, in the view tree. Scaffolding's macro generates the destination enum from your coordinator's functions, so a coordinator from another module slots in without touching any views.
  • Coordinators compose. The native version is glued to NavigationStack(path:) — embedding it as a tab, presenting it modally, or driving it from another flow means re-plumbing the path bindings. A Scaffolding coordinator is a first-class value that any other coordinator can route(to:), present(_:as:), or hold as a tab.

When to use what

Scaffolding exists to give NavigationStack the modularity it lacks — coordinators, child coordinators, and route(to:) that compose across module boundaries. That's the core value the library delivers.

Modal presentations sit one level above that, and they have two flavors. The right tool depends on what's inside the modal:

  • View-only Reach for SwiftUI's native .sheet(item:) / .fullScreenCover(item:) when the modal is a single screen — a confirmation, an info dialog, a simple form. Keep it native; the view-side modifier is lighter and avoids any coordinator overhead.
  • Sub-flow Use present(_:as:) when the modal is itself a flow — a Login coordinator with email → password → done, a Settings hierarchy, anything with its own navigation. The presented coordinator gets a parent reference, can call dismissCoordinator() on itself, and delivers results back through onComplete callbacks the parent installs at construction time.

Rule of thumb: if the modal contains navigation, make it a coordinator and present; if it's a single-page view, use SwiftUI's native modifier.