dotaeva/scaffolding
**Macro-powered SwiftUI navigation that stays out of your way.**
At a Glance
@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()Why Scaffolding?
| | 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.
Installation
Add Scaffolding via Swift Package Manager:
https://github.com/dotaeva/scaffolding.gitRequirements: iOS 17+ / macOS 14+ · Swift 5.9+ · Xcode 15+
Three Coordinator Types
FlowCoordinatable — Navigation Stacks
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 |
TabCoordinatable — Tab Bars
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 |
RootCoordinatable — State Switches
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)Full Example
@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"))
}
}Advanced Usage
Nested Routing
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)
}
}Environment Access
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)
}
}
}Destination Metadata
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 globallyCustom View Wrapping
Apply shared modifiers to all views in a coordinator:
@ScaffoldingIgnored
func customize(_ view: AnyView) -> some View {
view
.navigationBarTitleDisplayMode(.inline)
.toolbar { /* shared toolbar */ }
}Cross-Module Navigation
Mark a coordinator as public to expose its routes across modules — a natural fit for modular architectures.
Macros Reference
| Macro | Target | Purpose | |---|---|---| | @Scaffoldable | Class | Generates Destinations enum from methods | | @ScaffoldingIgnored | Method | Excludes a method from destination generation |
Example Project
A full example using Tuist and The Modular Architecture is available here.
<div align="center">
MIT License
</div>
Package Metadata
Repository: dotaeva/scaffolding
Default branch: main
README: README.md