Scaffolding

Tutorial · 25 minutes

Your first Scaffolding project.

Build a three-screen iOS app — list, detail, and settings — using a single FlowCoordinatable. By the end you'll have push navigation, a sheet, and a coordinator that the macro generates routes for automatically.

01 Set up the project

Create a new iOS App project in Xcode. Choose SwiftUI for the interface and name it ScaffoldingDemo.

Add Scaffolding via File → Add Package Dependencies, enter https://github.com/dotaeva/scaffolding.git, and add the Scaffolding library to your app target.

ScaffoldingDemoApp.swift
import SwiftUI

@main
struct ScaffoldingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            Text("Coming soon")
        }
    }
}

02 Create your coordinator

Define a FlowCoordinatable that manages navigation between three screens — Home, Detail, and Settings. Mark it @Scaffoldable @Observable; the macro inspects each function and generates the type-safe Destinations enum at compile time.

Start with an empty class and a FlowStack rooted at .home — a case the macro will generate from the home() function you add next.

AppCoordinator.swift · empty
import SwiftUI
import Scaffolding

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

Add a home() function that returns some View. This becomes the stack's root destination and the macro generates a .home enum case from the function name.

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

    func home() -> some View { Text("Home") }
}

Add a detail(title:) function with a parameter — the macro generates a .detail(title:) enum case with a matching associated value.

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

    func home()              -> some View { Text("Home") }
    func detail(title: String) -> some View { Text(title) }
}

Add settings() to round out the routes. You now have three: .home, .detail(title:), and .settings — generated automatically.

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

    func home()                -> some View { Text("Home") }
    func detail(title: String) -> some View { Text(title) }
    func settings()            -> some View { Text("Settings") }
}

03 Build the views

Each screen is a regular SwiftUI view that resolves its coordinator via @Environment — Scaffolding injects every coordinator in the hierarchy automatically.

HomeView.swift
import SwiftUI
import Scaffolding

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

    var body: some View {
        Text("Hello, \(String(describing: coordinator))")
    }
}
DetailView.swift
struct DetailView: View {
    let title: String
    var body: some View { Text(title).font(.title) }
}
SettingsView.swift
struct SettingsView: View {
    @Environment(AppCoordinator.self) private var coordinator

    var body: some View {
        Form {
            Button("Done") {
                coordinator.pop()
            }
        }
        .navigationTitle("Settings")
    }
}

SettingsView calls coordinator.pop() to dismiss itself — modals share the same FlowStack.destinations array as pushes, so popping the topmost destination removes the sheet.

05 Present a sheet

Replace the placeholder views with the real ones, then add a toolbar button to HomeView that presents settings as a sheet. Use present(_:as:)route(to:) is push-only.

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

    func home()                -> some View { HomeView() }
    func detail(title: String) -> some View { DetailView(title: title) }
    func settings()            -> some View {
        NavigationStack { SettingsView() }
    }
}
HomeView.swift · sheet
struct HomeView: View {
    @Environment(AppCoordinator.self) private var coordinator
    let items = ["Mercury", "Venus", "Earth", "Mars"]

    var body: some View {
        List(items, id: \.self) { item in
            Button {
                coordinator.route(to: .detail(title: item))
            } label: { Label(item, systemImage: "globe") }
        }
        .navigationTitle("Planets")
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Button {
                    coordinator.present(.settings, as: .sheet)
                } label: {
                    Image(systemName: "gear")
                }
            }
        }
    }
}

Pass .fullScreenCover in place of .sheet for a full-screen modal. Both live in the same FlowStack.destinations array as the pushed destinations.

With everything wired, this is what the user gets — push a planet, tap the gear for settings, dismiss to come back:

06 Wire up the app

Connect the coordinator to your app entry point. Read the coordinator.view property inside WindowGroup — it's a computed property (no parentheses) that renders the entire navigation hierarchy as a SwiftUI view.

ScaffoldingDemoApp.swift · final
import SwiftUI

@main
struct ScaffoldingDemoApp: App {
    @State private var coordinator = AppCoordinator()

    var body: some Scene {
        WindowGroup {
            coordinator.view
        }
    }
}

Build and run. You'll see a list of planets — tap one to push the detail screen, tap the gear to present settings as a sheet, tap Done to dismiss it.