Skip to content

3.4.0

Latest

Choose a tag to compare

@dotaeva dotaeva released this 06 Aug 18:40
· 2 commits to main since this release

Restoration, awaited navigation, and a testing library

3.3.0 shipped dismissModal() and the shouldSelect hook. Everything below has landed since — the quality-of-life pass on the navigation API, navigation-state snapshots, async/await navigation, and a second library for testing coordinators.

Navigation state snapshots

Capture the live coordinator tree and replay it on the next launch: current root, tab selection, every flow's push stack, and presented modals, recursively.

@Scaffoldable(codable: true) @Observable
final class HomeCoordinator: @MainActor FlowCoordinatable {  }

let data = try appCoordinator.captureNavigationState()   // opaque Data — persist anywhere
try appCoordinator.restoreNavigationState(from: data)    // replay onto a fresh tree
  • @Scaffoldable(codable: true) makes the generated Destinations enum Codable; route payloads must then be Codable themselves.
  • Coordinators that don't opt in degrade gracefully — their subtree restores at its initial position instead of failing. Only calling captureNavigationState() directly on a non-codable coordinator throws (NavigationStateError.unsupported).
  • Routes that no longer decode after an app update are skipped, so a stale snapshot never breaks launch.
  • FlowStack(root:pushing:) seeds a known deep position without any persistence — useful for deep-link fallbacks, previews, and tests.

Awaited navigation

Navigation that reads like a function call, as an alternative to onDismiss: and onComplete: closures:

await routeAndWait(to: .categoryPicker)                  // resumes when it pops
await presentAndWait(.onboarding, as: .fullScreenCover)  // resumes when it closes

guard let limit = await present(.limitPicker, awaiting: Decimal.self) else { return }

The suspension resumes exactly once however the destination leaves — pop(), popToRoot(), a back swipe, a root swap, dismissModal(), or the coordinator being dismissed. Any dismissal that isn't dismissCoordinator(returning:) resumes with nil, so cancellation needs no extra code.

Navigation API

  • Modal dismissal from the presenter: dismissModal() closes the topmost modal and fires its onDismiss exactly once; dismissAllModals() clears the stack. Both are available on every coordinator type and never touch pushed destinations.
  • Presenter-side sheet configuration: present(_:as: .sheet(detents:dragIndicator:interactiveDismissDisabled:)). The presented view stays ignorant of its presentation.
  • RoutePolicy.distinct skips a route when the same case is already on top or already presented — a one-word double-tap guard.
  • New stack operations: pop(_ count:) (stops at the root, never dismisses the coordinator) and replaceLast(with:) (back skips the replaced screen).
  • Stack introspection: depth, topDestination, count(of:), isPresentingModal alongside the existing isInStack(_:).
  • Tab badges: setBadge(_:for:) (numeric or text, 0/nil clears) and badge(for:), plus isInTabItems(_:).
  • expecting: overloads on every navigation call that resolves a child coordinator — a flat alternative to the typed trailing closures for deep-link chains.
  • Cross-coordinator results: dismissCoordinator(returning:) hands a value to a presenter awaiting present(_:awaiting:).

Orientation and debugging

  • ancestor(ofType:) walks the parent chain, routeType reports how a coordinator was presented (.root / .push / .sheet / .fullScreenCover, with isModal), and hierarchyRoot reaches the top of the tree. These replace the untyped Navigator environment value — views that only need to close or go back use SwiftUI's @Environment(\.dismiss).
  • debugHierarchy() prints the live tree, side-effect free: a child that hasn't been created is reported, never materialised.
  • hierarchySnapshot() returns that same tree as [HierarchyNode] (role, meta, coordinator, hasCoordinator, children) for debug UIs and assertions.

ScaffoldingTesting

A new library product for test targets — never link it into an app target, since it imports Swift Testing.

let home = HomeCoordinator().activated()      // resolve the initial root
home.open(transaction)
#expect(home.topDestination == .transaction)

app.handle(URL(string: "myapp://holding/NVDA")!)
#expect(app.hierarchyContains(InvestCoordinator.self, .holding, as: .push))
  • activated() resolves the initial root/tabs that the framework would otherwise resolve on first render. Without it, topDestination, isRoot(_:), and debugHierarchy() read as empty in tests.
  • descendant(ofType:) / descendants(ofType:) return a typed handle on an already-created child — the only way into a flow the code under test presented itself, since present(_:awaiting:) deliberately hands back no coordinator.
  • hierarchyContains(_:_:) / (_:_:as:) assert on the whole tree with types instead of matching debugHierarchy() output. Roles are .root, .push, .sheet, .fullScreenCover, .tab(index:isSelected:).
  • waitUntil { … } spins the main actor for the awaitable API and records an issue instead of hanging on timeout.

Macro

  • @Scaffoldable now infers the coordinator kind from its state container (FlowStack / TabItems / Root) when the conformance is spelled through a protocol of your own — protocol TabFlow: FlowCoordinatable previously failed, because a macro sees syntax only.
  • @Scaffoldable(codable:) joins injectsCoordinator:; both default to preserving current behaviour.

Fixed

  • Tab badges did not refresh. TabView on iOS 26 stops re-evaluating its container body when a badge mutates on the observable TabItems; the badge is now part of the tab's render identity and mirrored through local state.

Documentation

  • Seven DocC tutorials instead of one, grouped into four chapters and all building the same app: fundamentals, tabs, authentication root swaps, modal sub-flows, deep linking, state restoration, testing. The fundamentals tutorial no longer teaches a NavigationStack nested inside a flow's sheet, or dismissCoordinator() where dismissModal() belongs.

  • Async/await is the documented default: a new Awaiting Navigation section in the guide, with the tutorials teaching the awaited and flat expecting: forms rather than closures. 11 stale doc links from the 3.3 API changes are fixed, and the catalog builds warning-free.

  • README and AGENTS.md cover the full current surface.

  • Agent skills. The agent guide is now five progressive-disclosure skills, and the repo doubles as a Claude Code plugin marketplace:

    claude plugin marketplace add dotaeva/scaffolding && \
      claude plugin install scaffolding@scaffolding

Example

Example/Demo replaces the three-screen sample with a banking-style app covering the whole surface: auth root swap, a tab coordinator with a custom glass bar and a gated tab, four independent flows, presented sub-flows returning values, sheet configuration, deep links, and snapshot save/restore. Its test target carries 44 Swift Testing cases over the shipping coordinators. It's a plain Xcode project — open Example/Demo/Demo.xcodeproj, then ⌘R or ⌘U.

Infrastructure

The hand-written Svelte site is gone; DocC alone is published to GitHub Pages, which moves the documentation to /scaffolding/documentation/scaffolding/.

Migration

Source-compatible from 3.3.0 with one exception: @Environment(\.navigator) and the Navigator type were removed. Use @Environment(\.dismiss) for close/back, or the typed coordinator (@Environment(MyCoordinator.self), ancestor(ofType:)) for anything else. Navigator was never part of a tagged release.