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.
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
- Create a class and mark it
@Scaffoldable @Observable. - Conform to one of three coordinator protocols.
- Write functions — each one becomes a route.
- The macro generates a
Destinationsenum from those functions. - Push with
coordinator.route(to: .someDestination)or present a modal withcoordinator.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 type | What it creates | Typical use |
|---|---|---|
some View | A view destination | Simple screens |
any Coordinatable | A child coordinator | Nested navigation flows |
(any Coordinatable, some View) | Coordinator + tab label | Tab bar tabs |
(some View, some View) | View + tab label | View-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:
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:
#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.
// ❌ 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)
}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.
@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:):
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.
@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:
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.
@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:
coordinator.setRoot(.authenticated)Environment access
Scaffolding injects every coordinator in the hierarchy into the
SwiftUI environment. Views access their nearest coordinator with @Environment:
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:
@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 isA 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.
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:
@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)
}
}Deep linking
Deep linking — landing the user on a specific screen straight
from a cold launch — is where the coordinator model
especially shines. 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:
// AppCoordinator.swift
//
// Deep-link entry point. From a cold launch (URL, push notification,
// quick action), drive the coordinator tree to the exact target
// state by chaining the typed overloads — each `<T: Coordinatable>`
// variant gives you a typed handle on the resolved child once the
// route lands.
@Scaffoldable @Observable
final class AppCoordinator: @MainActor RootCoordinatable {
var root = Root<AppCoordinator>(root: .unauthenticated)
func unauthenticated() -> any Coordinatable { LoginCoordinator() }
func authenticated() -> any Coordinatable { MainTabCoordinator() }
/// Open the user's profile from a cold launch.
func openProfile(userId: Int) {
// 1. Swap the root to authenticated → returns the new
// MainTabCoordinator typed correctly.
setRoot(.authenticated) { (tab: MainTabCoordinator) in
// 2. Select the profile tab → typed handle on its
// ProfileCoordinator (a FlowCoordinatable).
tab.selectFirstTab(.profile) { (profile: ProfileCoordinator) in
// 3. Push the user-detail screen on the profile flow.
profile.route(to: .userDetail(id: userId))
}
}
}
}Each step of the chain hands the next step a typed
coordinator, so you can walk the tree
— root → tab → flow → pushed screen — without storing handles
anywhere or doing any runtime casting at the call site. The
same pattern composes for any depth, and the typed overload
is opt-in: when you don't need the child handle, the regular route(to:) / setRoot(_:) still works
without a trailing closure.
Hook the entry point to a URL, push notification, or quick action:
// MyApp.swift — wire the deep link from a URL.
@main
struct MyApp: App {
@State private var coordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
coordinator.view
.onOpenURL { url in
if let userId = parseUserURL(url) {
coordinator.openProfile(userId: userId)
}
}
}
}
}The typed closure only fires if the resolved child can be
cast to T. Pick a concrete coordinator type that
matches the destination's return signature — for an any Coordinatable route returning a ProfileCoordinator, the closure parameter must
be ProfileCoordinator.
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 (email → password).
When the user signs in on the password step, calling dismissCoordinator() closes the whole sheet at
once — not just the password screen.
@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)
}
}@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) }
}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:
@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.
NavigationStack(path:) // 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() }
}
}
}FlowCoordinatable// 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 pathand@State showSettingsinContentView; Scaffolding's coordinator owns theFlowStack, so views never store navigation state themselves. - Pushes are explicit calls, not
NavigationLinks. The native list couples the row to navigation throughNavigationLink(value:). With Scaffolding the row is a plainButtonthat callscoordinator.route(to: .detail(item:))— the view stays decoupled from how navigation happens. - Modals don't need a binding. Native modals require a
@State Booland a.sheet(isPresented:)modifier on the view tree. Scaffolding presents from a method call —coordinator.present(.settings, as: .sheet)— and dismisses withpop(). - Pop doesn't need a
@Binding. The nativeDetailViewtakes@Binding var pathso it can callremoveLast(). Scaffolding'sDetailViewreads the coordinator from@Environmentand just callspop(). - Type-safe destinations across modules. A heterogeneous
NavigationStackpath needs a custom enum and anavigationDestinationper 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 canroute(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 calldismissCoordinator()on itself, and delivers results back throughonCompletecallbacks 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.