Scaffolding

Changelog · latest 3.2.0

Scaffolding 3.2.0.

A simplification release. Destination tracking is now governed by a single rule — auto-track by return type, opt out with @ScaffoldingIgnored. The @ScaffoldingTracked opt-in macro and the concrete-coordinator return-type heuristic are both gone.

Source on GitHub. Compare with the library overview and the API reference. Looking for the 2.x → 3.0 migration? Skip down.

What's new in 3.2.0

  • Breaking The @ScaffoldingTracked macro is removed. There is no longer an explicit opt-in mode — destinations are always derived from the function's return type.
  • Breaking Concrete coordinator return types are no longer auto-tracked. A child-coordinator route must return any Coordinatable; a bare -> LoginCoordinator is now skipped like any other unrecognised type.
  • New One mental model: a function is a destination iff its return type is in the auto-tracked table (some View, any Coordinatable, or a tab tuple). Everything else — properties, Void helpers, closures, generics, arrays — is ignored automatically; @ScaffoldingIgnored is only for the function that returns a tracked type but isn't a route (e.g. customize(_:)).
3.1 → 3.2 · tracking model
// 3.1 — three ways to control tracking.
@ScaffoldingTracked                       // explicit opt-in (removed)
func detail() -> some View { DetailView() }

func login() -> LoginCoordinator { ... }  // concrete type, auto-tracked (removed)

// 3.2 — one rule. Return an auto-tracked type → it's a destination.
//        Opt out with @ScaffoldingIgnored. Nothing else.
func detail() -> some View { DetailView() }          // tracked
func login() -> any Coordinatable { LoginCoordinator() }  // tracked (existential)

If you never used @ScaffoldingTracked and always returned any Coordinatable for child coordinators — the documented guidance — this release is source-compatible. The only call sites that need a change are concrete-typed coordinator routes (switch them to any Coordinatable) and any @ScaffoldingTracked annotations (delete them).

What's new in 3.1.0

  • New present(_:as:onDismiss:) ships a <T: Coordinatable> overload on every coordinator type. The trailing closure receives the freshly-presented child, ready to seed before SwiftUI commits the sheet or full-screen cover.
HomeCoordinator · openSubscriptionAt
// 3.1 — present(_:as:) gains a `<T: Coordinatable>` overload
// that hands you the resolved child once the modal lands.

func openSubscriptionAt(planId: String) {
    present(.subscription, as: .sheet) { (sub: SubscriptionCoordinator) in
        // Seed the freshly-presented sub-flow before SwiftUI commits
        // the sheet — no IDs to forward, no @Environment lookups.
        sub.preselect(planId: planId)
    }
}

Same shape as route(to:_:) / setRoot(_:_:) — the cast only fires when the resolved destination matches T, so pick the concrete coordinator type returned by the route.

Earlier — Scaffolding 3.0

A focused breaking release. Push and modal presentation are now separate APIs, view becomes a property, the generic value-callback overloads are gone, and a long list of navigation-state bugs got fixed. The migration checklist below applies to 2.x → 3.0; 3.0 → 3.1.0 is source-compatible.

Highlights

  • Breaking coordinator.view() is now the property coordinator.view.
  • Breaking route(to:as:) is split into push-only route(to:) and modal-only present(_:as:).
  • Breaking The deep-link overloads (route<T>, setRoot<T>, appendTab<T>, insertTab<T>, popToFirst<T>, popToLast<T>, selectFirstTab<T>, selectLastTab<T>, select<T>(index:) / select<T>(id:)) keep their behaviour but tighten their signature: T is now constrained to Coordinatable, the trailing closure drops the value: label, and route loses its as: parameter.
  • New present(_:as:) is now available on TabCoordinatable and RootCoordinatable, not just FlowCoordinatable.
  • New @Scaffoldable(injectsCoordinator: Bool = true) opts a coordinator out of automatic environment injection.
  • Fix setRoot now wires up the parent chain on the new root and clears stale NavigationStack state from the previous root.
  • Fix dismissCoordinator() no longer leaks via retain cycles or leaves stale parent references behind.
  • Platform Deployment floor bumped to iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / macCatalyst 18. Strict-concurrency callbacks (onDismiss, deep-link trailing closures) are now @MainActor-typed.

view()view

Mounting a coordinator no longer goes through a function call. view is a SwiftUI-friendly computed property on every coordinator type, so it composes the same way any other view does — no parens, no method semantics to think about.

2.x · view() function
// 2.x
@main
struct MyApp: App {
    @State private var coordinator = AppCoordinator()

    var body: some Scene {
        WindowGroup {
            coordinator.view()      // function call
        }
    }
}
3.0 · view property
// 3.0
@main
struct MyApp: App {
    @State private var coordinator = AppCoordinator()

    var body: some Scene {
        WindowGroup {
            coordinator.view        // computed property — no parens
        }
    }
}

Same change applies wherever the old API was reached for — #Preview, UIKit hosting controllers, snapshot tests, etc.

route(to:as:) split into route and present

The old route(to:as:) overloaded a single call with three different transitions (push / sheet / cover). The signature looked uniform but the call sites read ambiguously: you had to remember whether the destination represented a screen meant to be pushed or a flow meant to be shown modally. 3.0 makes the distinction explicit at the API level.

2.x · combined route
// 2.x — one route() did everything.
coordinator.route(to: .detail(item: planet))           // push (default)
coordinator.route(to: .settings, as: .sheet)           // sheet
coordinator.route(to: .login,    as: .fullScreenCover) // full-screen cover
3.0 · route + present
// 3.0 — push and present are explicitly different calls.
coordinator.route(to: .detail(item: planet))           // push only
coordinator.present(.settings, as: .sheet)             // sheet
coordinator.present(.login,    as: .fullScreenCover)   // full-screen cover

// route(to:) no longer takes `as:` — pushes are the only thing it does.
// present(_:as:) accepts ModalPresentationType (sheet / fullScreenCover)
// at the call site; push is not a valid modal style by definition.

present(_:as:) takes a new ModalPresentationType (only .sheet and .fullScreenCover) so the type system also rules out the nonsensical "modal push." The richer PresentationType still exists internally for the unified-stack model that powers pop().

present(_:as:) on every coordinator type

Previously only FlowCoordinatable could present a modal. In 3.0 every coordinator type (FlowCoordinatable, TabCoordinatable, RootCoordinatable) implements present(_:as:), so a tab coordinator or root coordinator can host a sheet directly without delegating to a synthetic child flow.

TabCoordinatable hosts a sheet directly
// 3.0 — present(_:as:) is now available on every coordinator type.

@Scaffoldable @Observable
final class MainTabCoordinator: @MainActor TabCoordinatable {
    var tabItems = TabItems<MainTabCoordinator>(tabs: [.feed, .profile])

    func feed()    -> (any Coordinatable, some View) { /* … */ }
    func profile() -> (any Coordinatable, some View) { /* … */ }

    func upgrade() -> any Coordinatable { UpgradeCoordinator() }

    func showUpgrade() {
        present(.upgrade, as: .sheet)   // tab coordinator hosts the sheet
    }
}

dismissCoordinator() on the presented coordinator still dismisses the modal — pop() on the host does the same when the modal is the topmost destination (unified stack).

Deep-link overloads tightened

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 that hands you a typed reference to the resolved child once the route lands. That's the building block for deep linking: you walk the tree by chaining one overload per layer.

The overloads themselves are still here in 3.0 — what changed is their shape:

  • T is now constrained to Coordinatable. View destinations don't need typed access (they have nothing to drive), so the constraint cleans up the call site without losing functionality.
  • The trailing closure drops the value: label. It's now an unlabelled trailing closure, which reads naturally as "after routing, do this with the resolved child."
  • route drops as: (the modal/push split). Pushes use route(to:); modal entries via present(_:as:) have their own <T: Coordinatable> overload too.
  • Closures are @MainActor-typed, in line with the rest of the API.
2.x · value-labelled, unconstrained T, route-with-as
// 2.x — `<T>` was unconstrained, the closure was labelled `value:`,
// and `route` carried the `as:` parameter that's now on `present`.
appCoordinator.setRoot(.authenticated, value: { (tab: MainTabCoordinator) in
    tab.selectFirstTab(.profile, value: { (profile: ProfileCoordinator) in
        profile.route(to: .userDetail(id: 123), as: .push)
    })
})
3.0 · trailing closure, T: Coordinatable, route push-only
// 3.0 — `T` is constrained to Coordinatable, the closure trails
// without a label, and `route` no longer takes `as:`. Same pattern,
// tighter signature.
appCoordinator.setRoot(.authenticated) { (tab: MainTabCoordinator) in
    tab.selectFirstTab(.profile) { (profile: ProfileCoordinator) in
        profile.route(to: .userDetail(id: 123))
    }
}

The pattern itself hasn't changed — just the spelling. See Deep linking in the overview for a full walk-through and an example that lands on a profile screen from a cold launch.

Macro: injectsCoordinator

By default @Scaffoldable wires the coordinator into the SwiftUI environment so any view in its hierarchy can read it via @Environment(MyCoordinator.self). Sometimes that's the wrong default — a reusable coordinator embedded in a component shouldn't necessarily expose itself to surrounding views. Pass injectsCoordinator: false to opt out:

@Scaffoldable(injectsCoordinator: false)
// 3.0 — opt this coordinator out of automatic environment injection.
@Scaffoldable(injectsCoordinator: false) @Observable
final class ReusableCoordinator: @MainActor FlowCoordinatable {
    var stack = FlowStack<ReusableCoordinator>(root: .home)
    func home() -> some View { ReusableHomeView() }
}

The macro emits a nonisolated var _injectsCoordinator member so the framework can read the flag at runtime without the user having to forward it themselves.

Bug fixes

  • setRoot stale state. The new root's parent chain was not always wired before the old root's NavigationStack ran one more update pass, leaving stale push state visible during the swap. setRoot now resolves the new root atomically and clears the navigation stack of the previous one.
  • dismissCoordinator() retain cycles. The presenter held a strong reference to the presented coordinator's onDismiss, which itself captured the presenter — collapse-from-modal kept the whole sub-tree alive. The presenter ↔ presented edge is now resolved through a one-shot resolver that drops the back-reference once dismissal completes.
  • Parent chain on root coordinatables. FlowStack and Root now call setParent on the root coordinatable so child flows installed at the root can dismiss themselves and read the environment chain.
  • FlowStack.setRoot nil-unwrap crash. Replacing the root with a destination whose coordinatable resolved lazily could crash on a forced unwrap. The optional is now handled.
  • Nested-coordinator pushes invisible to NavigationStack. A child flow's pushed destinations could be filtered out of the parent's flat path under specific traversal orderings. Fixed by recursing through nested coordinator boundaries when flattening destinations.
  • Swift 6 strict-concurrency cleanup. Generated Destinations enum conformances no longer carry redundant @MainActor; closures and callbacks that touch coordinator state are @MainActor-typed at their declaration sites.

Compatibility

Requirement2.x3.0
Swift toolchain6.26.2
iOS1718
macOS1415
tvOS / watchOS / macCatalyst13 / 6 / 1318 / 11 / 18
@Observablerequiredrequired
Strict concurrencyopt-inrequired (@MainActor-typed callbacks)
TabRoleiOS 18 + @availablebaseline (no annotation needed)

The semver bump is driven by the API changes; the platform bump is the secondary breaking concern (consumers built against iOS 17 / macOS 14 won't link).

Migration checklist

  1. Required Replace every coordinator.view() call with the property access coordinator.view (drop the parens).
  2. Required Replace route(to: x, as: .sheet) / .fullScreenCover with present(x, as: .sheet) / .fullScreenCover. Keep route(to: x) for pushes; the as: argument no longer exists on route.
  3. Required Re-spell the deep-link <T> overloads. Drop the value: label (the closure is now an unlabelled trailing closure), make sure the typed parameter is a Coordinatable (view-typed Ts don't compile any more), and remove the as: argument from route. The overload still exists everywhere it did before, including a typed variant on present(_:as:) that hands you the resolved child once the modal lands.
  4. Compiler onDismiss and the deep-link trailing closures are now @MainActor-typed. If you stored or forwarded one as a plain () -> Void / (T) -> Void, update the type or annotate the closure.
  5. Platform Bump the consuming app's deployment target to iOS 18 / macOS 15 (and tvOS 18 / watchOS 11 / macCatalyst 18 if you ship those). The package no longer links against the old floors.
  6. Optional If you have coordinators whose views shouldn't see them in the environment, opt out with @Scaffoldable(injectsCoordinator: false).
  7. Optional Modal hosting can be moved up the tree where it makes sense — present(_:as:) now works on TabCoordinatable and RootCoordinatable directly, so you no longer have to delegate every modal to a child flow.

Once the call-site changes are in, the underlying mental model is unchanged: coordinators own navigation state, views read coordinators from @Environment, route(to:) pushes, present(_:as:) shows a modal, dismissCoordinator() closes a sub-flow.