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))
coordinator.route(to: .settings, as: .sheet)
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.
Add Scaffolding via Swift Package Manager:
https://github.com/dotaeva/scaffolding.git
Requirements: iOS 17+ / macOS 14+ · Swift 5.9+ · Xcode 15+
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:as:) |
Navigate to a destination (push, sheet, or fullScreenCover) |
pop() |
Pop the current view |
popToRoot() |
Return to the root |
popToFirst(_:) / popToLast(_:) |
Pop to a specific destination |
setRoot(_:) |
Replace the root destination |
isInStack(_:) |
Check if a destination exists in the stack |
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)
}
}
TabRolesupport requires iOS 18+.
API:
| Method | Description |
|---|---|
selectFirstTab(_:) / selectLastTab(_:) |
Select a tab by destination |
select(index:) / select(id:) |
Select by index or ID |
appendTab(_:) / insertTab(_:at:) |
Add tabs dynamically |
removeFirstTab(_:) / removeLastTab(_:) |
Remove tabs |
setTabs(_:) |
Replace all tabs |
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)@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"))
}
}Navigate through multiple coordinator layers in a single call:
coordinator.route(to: .settings) { (settings: SettingsCoordinator) in
settings.route(to: .accountDetails) { (account: AccountCoordinator) in
account.setUser(currentUser)
}
}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 → how this view was presented globallyApply 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 |
Class | Generates Destinations enum from methods |
@ScaffoldingIgnored |
Method | Excludes a method from destination generation |
A full example using Tuist and The Modular Architecture is available here.
MIT License