Macro-powered SwiftUI navigation that stays out of your way.
Define routes as functions. Get type-safe navigation for free.
@Scaffoldable @Observable
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() }
}That's it. The @Scaffoldable macro generates a Destinations enum from your methods. No manual enums, no switch statements, no boilerplate.
coordinator.route(to: .detail(item: selectedItem)) // push
coordinator.present(.settings, as: .sheet) // sheet (sub-flow)
coordinator.pop()NavigationLink |
NavigationStack(path:) |
Scaffolding | |
|---|---|---|---|
| Navigation in UI layer | Yes | Yes | No |
| Type-safe destinations | No | Partial | Yes |
| Nested coordinator flows | No | Manual | Built-in |
| Modular architecture | Hard | Possible | Natural |
| Boilerplate | Low | Medium | Minimal |
If your app has a couple of screens, NavigationLink is fine. Once you have multiple flows, deep linking, or modular architecture — Scaffolding keeps things clean.
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.
For modals, pick the lightest tool that fits:
- SwiftUI's native
.sheet(item:)/.fullScreenCover(item:)when the modal is a single view — a confirmation, an info dialog, a simple form. Keep it native; the view-side modifier is simpler and avoids coordinator overhead. - Scaffolding's
present(_:as:)when the modal is a sub-flow — a Login flow 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 viaonCompletecallbacks.
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.
⚠ Don't nest
NavigationStackinside a flow.
FlowCoordinatableis theNavigationStack, so putting another one inside any of its destination views breaks navigation — SwiftUI doesn't composeNavigationStacks with each other, and the nested stack swallows the pushes that should belong to the parent flow.If a screen needs its own navigation hierarchy, route to a child
FlowCoordinatableinstead, orpresent(_:as:)a sub-flow modally. Each coordinator boundary creates a freshNavigationStack, which is the only configuration SwiftUI handles correctly.
Add Scaffolding via Swift Package Manager:
https://github.com/dotaeva/scaffolding.git
Requirements: iOS 18+ · macOS 15+ · tvOS 18+ · watchOS 11+ · macCatalyst 18+ · Swift 6.2 · Xcode 16+
Push, pop, and present modals. The workhorse of most apps.
@Scaffoldable @Observable
final class MainCoordinator: @MainActor FlowCoordinatable {
var stack = FlowStack<MainCoordinator>(root: .home)
func home() -> some View { HomeView() }
func detail() -> some View { DetailView() }
func profile() -> any Coordinatable { ProfileCoordinator() }
}API:
| Method | Description |
|---|---|
route(to:onDismiss:) |
Push a destination onto the stack |
present(_:as:onDismiss:) |
Show a destination as a .sheet or .fullScreenCover |
pop() / popToRoot() |
Pop the topmost / everything-above-root |
popToFirst(_:) / popToLast(_:) |
Pop to a specific destination by Meta |
setRoot(_:animation:) |
Replace the root destination |
dismissCoordinator() |
Remove the whole coordinator from its parent |
isInStack(_:) |
Check whether a destination exists in the stack |
Each of these methods also exposes a <T: Coordinatable> overload with a trailing closure — handy for deep linking.
Each tab gets its own coordinator. Nest full navigation flows inside tabs.
@Scaffoldable @Observable
final class AppCoordinator: @MainActor TabCoordinatable {
var tabItems = TabItems<AppCoordinator>(tabs: [.home, .profile, .search])
func home() -> (any Coordinatable, some View) {
(HomeCoordinator(), Label("Home", systemImage: "house"))
}
func profile() -> (any Coordinatable, some View) {
(ProfileCoordinator(), Label("Profile", systemImage: "person"))
}
func search() -> (any Coordinatable, some View, TabRole) {
(SearchCoordinator(), Label("Search", systemImage: "magnifyingglass"), .search)
}
}API:
| Method | Description |
|---|---|
selectFirstTab(_:) / selectLastTab(_:) |
Select a tab by Meta |
select(index:) / select(id:) |
Select by index or UUID |
appendTab(_:) / insertTab(_:at:) |
Add tabs dynamically |
removeFirstTab(_:) / removeLastTab(_:) |
Remove tabs |
setTabs(_:) |
Replace all tabs |
present(_:as:onDismiss:) |
Show a destination as a .sheet or .fullScreenCover |
Swap the entire view hierarchy. Perfect for auth flows.
@Scaffoldable @Observable
final class AuthCoordinator: @MainActor RootCoordinatable {
var root = Root<AuthCoordinator>(root: .login)
func login() -> some View { LoginView() }
func authenticated() -> any Coordinatable { MainAppCoordinator() }
}One call flips the entire app state:
coordinator.setRoot(.authenticated)RootCoordinatable also exposes present(_:as:onDismiss:), so a root coordinator can host a sheet or full-screen cover directly without delegating to a child flow.
@main
struct MyApp: App {
@State private var appCoordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
appCoordinator.view
}
}
}
@Scaffoldable @Observable
final class AppCoordinator: @MainActor RootCoordinatable {
var root = Root<AppCoordinator>(root: .unauthenticated)
func unauthenticated() -> any Coordinatable { LoginCoordinator() }
func authenticated() -> any Coordinatable { MainTabCoordinator() }
}
@Scaffoldable @Observable
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"))
}
}Every navigation method that resolves a child coordinator
(route, present, setRoot, appendTab, insertTab, popToFirst,
popToLast, selectFirstTab, selectLastTab, select(index:),
select(id:)) ships a <T: Coordinatable> overload with a trailing
closure that hands you a typed reference to the resolved child once the
route lands. Chain them to walk the tree from a cold launch:
appCoordinator.setRoot(.authenticated) { (tab: MainTabCoordinator) in
tab.selectFirstTab(.profile) { (profile: ProfileCoordinator) in
profile.route(to: .userDetail(id: userId))
}
}The closure only fires if the resolved destination can be cast to T,
so pick the concrete coordinator type that matches the route's return
signature.
Coordinators are automatically injected into the SwiftUI environment. The closest matching coordinator in the view hierarchy is used.
struct DetailView: View {
@Environment(MainCoordinator.self) var coordinator
var body: some View {
Button("Next") {
coordinator.route(to: .nextScreen)
}
}
}Each view can inspect how it was presented via the \.destination environment value:
@Environment(\.destination) private var destination
// destination.routeType → .root, .push, .sheet, or .fullScreenCover
// destination.presentationType → effective presentation style
// destination.meta → which generated case this destination isA common use is a single reusable bar that adapts to context — back chevron when pushed, "Close" when presented as a sheet, nothing when it's the root:
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)
}
}Apply shared modifiers to all views in a coordinator:
@ScaffoldingIgnored
func customize(_ view: AnyView) -> some View {
view
.navigationBarTitleDisplayMode(.inline)
.toolbar { /* shared toolbar */ }
}Mark a coordinator as public to expose its routes across modules — a natural fit for modular architectures.
| Macro | Target | Purpose |
|---|---|---|
@Scaffoldable(injectsCoordinator: Bool = true) |
Class | Generates Destinations enum from methods. Pass injectsCoordinator: false to opt this coordinator out of automatic environment injection. |
@ScaffoldingTracked |
Method | Explicit opt-in: once applied to any method on a coordinator, only methods carrying it become destinations. |
@ScaffoldingIgnored |
Method | Excludes a method from destination generation (e.g. a customize(_:) override). |
A full example using Tuist and The Modular Architecture is available here.
MIT License