agentsclimarketplace

Swiftui navigation architecture

Skill wei18/apple-dev-skills/apple-dev-skills/skills/swiftui-navigation-architecture

Default navigation shape for SwiftUI Apps (iOS 18 / macOS 15, Swift 6) — value-based `NavigationStack(path:)` over a typed `Route` enum, one `@Observable @MainActor` Router in `.environment`, `navigationDestination(for:)` at the stack root (never in a lazy container), per-transition presentation semantics (push / sheet / `fullScreenCover` / popover / alert / root-swap) incl. macOS behavior (no native `fullScreenCover` → push fallback, pop-to-landing), `item:`-driven modal optionals, `.onOpenURL` deep-link funnel, `NavigationSplitView`, per-tab paths, `Codable` restoration. Invoke when wiring an App's navigation, choosing sheet vs cover vs push, adding deep links / restoration, migrating off `NavigationView`, or asked "router / coordinator in SwiftUI". Bugs → swiftui-interaction-footguns.From its SKILL.md

Install
npx -y skills add wei18/apple-dev-skills --skill swiftui-navigation-architecture

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

13.4 KB, ~3.0k tokens by cl100k_base, as published. Nobody here has run it

SwiftUI Navigation Architecture

The default navigation shape for Apps on this catalog's baseline ([[apple-platform-targets]]: iOS 18 / macOS 15, Swift 6 language mode): navigation state is data — a typed route enum in one observable router — so flows are unit-testable (assert on [Route]), deep-linkable (URL → routes is a pure function), and restorable (routes are Codable).

When to invoke

  • Wiring navigation in a new App target, or adding a second entry point (deep link, widget tap, notification, tab)
  • Adding deep links / universal links or state restoration to an existing App
  • Migrating off NavigationView or view-payload NavigationLink(destination:)
  • Asked "how do I do a router / coordinator in SwiftUI?"

Scope

Owns container choice (Stack / SplitView / Tab), route modeling, the router object, deep-link funneling, and restoration. Does NOT own: known interaction bugs in nav components → [[swiftui-interaction-footguns]]; injecting the services destination views need → [[swift-dependency-injection]]; nav-chrome accessibility → [[ios-accessibility-engineering]].

Pick the container

App shapeContainer
Single drill-down flow (iPhone-first)NavigationStack(path:)
2–5 top-level peer sectionsTabView + one NavigationStack per tab, each with its own path
Source list → detail (iPad / Mac)NavigationSplitView; sidebar selection is router state, the detail column hosts its own NavigationStack

Never NavigationView in new code — deprecated since iOS 16.

The default shape

enum Route: Hashable, Codable {
    case board(id: UUID)      // payloads are IDs, not model objects
    case settings
}

enum Modal: String, Identifiable {  // presentation is NOT navigation
    case paywall, onboarding
    var id: String { rawValue }
}

@Observable @MainActor
final class Router {
    var path: [Route] = []
    var modal: Modal?

    // One pure mapping: URL → navigation state. Unit-test without UI.
    func open(_ url: URL) {
        guard url.scheme == "myapp" else { return }
        switch url.host {
        case "board": path = [.board(id: UUID(uuidString: url.lastPathComponent) ?? UUID())]
        case "settings": path = [.settings]
        default: break
        }
    }
}

@main struct MyApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        switch route {                 // exhaustive — compiler catches new routes
                        case .board(let id): BoardView(id: id)
                        case .settings: SettingsView()
                        }
                    }
            }
            .environment(router)
            .sheet(item: $router.modal) { ModalHost($0) }
            .onOpenURL { router.open($0) }
        }
    }
}

Rules the shape encodes:

  • Push with valuesNavigationLink(value:) or router.path.append(...); never NavigationLink(destination:) in a path-managed stack (those pushes bypass path, desyncing back-stack, deep links, and restoration).
  • navigationDestination(for:) on the stack's root content, outside lazy containers. Apple's docs: "Do not put a navigation destination modifier inside a 'lazy' container, like List or LazyVStack. … Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination."
  • Typed [Route] over NavigationPath — pattern-matchable, exhaustively switched, Codable for free.
  • Modal ≠ push — presented flows hang off router optionals (item:-driven; one optional per presentation kind — sheet, cover, alert. Parallel isPresented: Bools race and can present blank); a presented flow is never a Route case. Which kind → next section.
  • Router is @MainActor (it is UI state; Swift 6 enforces it), routes are Hashable + Codable value types.

Presentation semantics (decide per transition — iOS and macOS)

"Where does the user land when this closes?" is decided by the semantic you pick, not by the destination view. Pick the tag first; the API and its back/dismiss behavior follow.

SemanticAPIDismissaliOS / iPadOSmacOS
pushNavigationStack + navigationDestinationback pops one level; edge-swipe on by defaultsame model, toolbar back
sheet.sheet(item:) (+ .presentationDetents)swipe-to-dismiss on by default; interactiveDismissDisabled(true) to force completiondetents resize; >1 detent shows the grabberwindow-styled sheet — detents don't drive sizing, size the content itself
full-screen cover.fullScreenCover(item:)no interactive dismiss; exit only via explicit closeopaque, covers everythingunavailable on native macOS (Mac Catalyst only) — fall back to push/sheet, see below
popover.popovertap outsidecollapses to a sheet in compact width unless .presentationCompactAdaptation(.popover)always a true anchored popover
alert.alert(_:isPresented:presenting:)button tap only — no scrim tap, no swipedata-drive off one optionalsame
dialog.confirmationDialogCancel or tap outsidebottom action sheetrendered alert-style
root-swapconditional root content on @Observable statenone — the outgoing tree is destroyedauth gates, onboarding-donesame

Two contract rules the table enforces:

  • "Closes on outside tap" is a semantic, not a style — if that's the requirement, it's a confirmationDialog or popover, never an .alert, regardless of visual intent.
  • Login/logout is root-swap, not a modal. Conditionally render LoginView vs AppView at the root off auth state: success flips the flag and the login tree is destroyed; logout flips it back and tears down the entire app stack (paths, sheets, all of it). Presenting the app over login couples app lifetime to a presentation and turns logout into "dismiss a modal" with login still alive underneath.

macOS fallback for full-screen flows

A flow covered by fullScreenCover on iOS ships as a push (or sheet) on macOS — and Close must land on the same screen on both platforms. The trap: dismiss() closes the whole cover, but the pushed variant's naive path.removeLast() pops one level and strands the user mid-flow.

extension Router {
    // One flow, two containers: cover on iOS, push on macOS.
    func startGame(id: UUID) {
        #if os(macOS)
        path.append(.board(id: id))
        #else
        cover = .board(id: id)     // .fullScreenCover(item: $router.cover)
        #endif
    }
    func closeGame() {             // lands on the hub on BOTH platforms
        #if os(macOS)
        path.removeAll()           // pop the whole flow — pop-one strands mid-flow
        #else
        cover = nil
        #endif
    }
}

Route both entry and exit through router methods (as above) so no view ever encodes the platform branch.

Deep links & restoration

  • Every URL entry point (onOpenURL, universal links, NSUserActivity, notification taps) funnels into router.open(_:) at the scene root — one handler, one mapping function, unit tests assert URL → [Route] directly.
  • Restoration: on scenePhase == .background, JSONEncoder the [Route] into @SceneStorage(a String slot); on launch, decode and assign. Routes that reference store objects carry IDs — resolve at display time and drop routes that no longer resolve instead of crashing.

Multi-column & tabs

  • NavigationSplitView: sidebar selection lives in the router; only the detail column hosts a NavigationStack. Don't nest stacks in the sidebar.
  • TabView: the router owns selectedTab and one path per tab — paths kept in per-view @State reset whenever tab identity churns. Selecting the already-active tab popping to root becomes a 2-line router method.
  • iPad / Mac footguns in these containers (sizeClass on Mac, inert sidebar Labels) → [[swiftui-interaction-footguns]].

Rationale

  • Navigation state as data: view-payload links hide "where is the user" inside the view tree; a typed path makes it assertable, loggable, and reproducible from a URL or a saved session.
  • One router in .environment: a single owner instead of bindings threaded through N view layers; @Observable keeps invalidation scoped to views that actually read path.
  • Typed enum over NavigationPath: exhaustive switch in navigationDestination means the compiler flags every unhandled route; NavigationPath trades that away for type erasure most single-module apps don't need.

Deviation considerations

  • Heterogeneous route types across feature packages that genuinely can't share one enum → NavigationPath + its CodableRepresentation for restoration; you give up exhaustive matching.
  • A 2-screen utility → a bare NavigationStack without router or path is fine; adopt the shape when the second entry point appears, not speculatively.
  • UIKit-hosted hybrids (heavy UIViewController interop) → keep coordination at the UIKit layer; don't force a SwiftUI router across the hosting bridge.
  • iOS 17 floor → the shape works unchanged (@Observable, NavigationStack both available); below that, ObservableObject replaces @Observable.
  • Mac CatalystfullScreenCover is available there; the push/sheet fallback is for native (AppKit-based) macOS targets.

Common Mistakes

  1. NavigationView in new code — deprecated since iOS 16, unpredictable column behavior. Use NavigationStack / NavigationSplitView.
  2. navigationDestination(for:) inside List / LazyVStack — the lazy container may not have created the registering view yet, so pushes silently fail (runtime console warning). Register at the stack root.
  3. Mixing NavigationLink(destination:) into a path-based stack — pushes invisible to path; back-stack count, deep links, and restoration all desync.
  4. Sheets modeled as pushed routes — back-button vs dismiss semantics conflict; keep a separate Modal enum.
  5. Per-tab paths in view @State — switching tabs (or any identity churn) resets the stack; paths belong to the router.
  6. onOpenURL sprinkled across views — multiple competing handlers; one scene-root handler feeding one mapping function.
  7. Router not @MainActor — Swift 6 isolation errors the first time a Task mutates path; annotate the class, not call sites.
  8. Model objects as route payloads — bloats Hashable/Codable, goes stale after edits; carry IDs and resolve at display.
  9. @Environment(\.dismiss) read in the presenter — it only dismisses when read inside the presented content; in the presenter it's a no-op. Presenter-side closing = nil out the router optional.
  10. Shared Close across an iOS cover / macOS push split that pops one level — on macOS the user lands mid-flow instead of on the hub; branch close to pop-to-landing (see the macOS fallback).
  11. Relying on automatic dismiss cascades across stacked presentations — model dismiss() vs dismissAll() as distinct router methods; chain a follow-up presentation in the prior one's onDismiss, never present-while-presenting.

Review Checklist

  • No NavigationView; no NavigationLink(destination:) inside path-managed stacks
  • One Route enum, Hashable + Codable; payloads are IDs, not model objects
  • navigationDestination(for:) registered at the stack root, outside lazy containers
  • Single @Observable @MainActor router in .environment; all paths (incl. per-tab) live on it
  • Sheets / covers driven by item: off router optionals (one per presentation kind), never a Route case or parallel Bools
  • Every transition names its presentation semantic and where close/back lands
  • Full-screen flows branch per platform; Close lands identically on iOS and macOS (pop-to-landing, not pop-one)
  • Auth / onboarding gates are root-swap, not modals over the app
  • One .onOpenURL at the scene root; URL → [Route] mapping has unit tests
  • Restoration decodes saved routes and drops unresolvable IDs gracefully
  • SplitView: sidebar selection in router, stack only in detail; footguns checklist run

Related skills

  • [[swiftui-interaction-footguns]] — known bugs in the nav components this skill wires together
  • [[swift-dependency-injection]] — how destination views get their services
  • [[apple-platform-targets]] — the iOS 18 / macOS 15 baseline this shape assumes
  • [[ios-accessibility-engineering]] — accessibility of the navigation chrome

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Gives 0 of the 12 instructions most architecture codebase skills give in ~3.0k tokens

Counted across 811 of the 1,134 authors here whose files we hold, read 2026-08-07

  • Ask the user which candidate to explorein 45 of 811, across 15 files
  • Apply the deletion test to suspected shallow modulesin 43 of 811, across 15 files
  • Read any relevant architecture decision records firstin 31 of 811, across 8 files
  • Use exact glossary terms in every suggestionin 30 of 811, across 10 files
  • Accept dependencies instead of creating themin 24 of 811, across 5 files
  • Include before and after visualisations for each candidatein 24 of 811, across 5 files
  • Read the domain glossary before exploringin 24 of 811, across 6 files
  • Return results instead of producing side effectsin 23 of 811, across 4 files
  • Explore the codebase for shallow modules and frictionin 23 of 811, across 3 files
  • Introduce seams only where things varyin 22 of 811, across 3 files
  • Reduce the number of methodsin 21 of 811, across 2 files
  • Design deep modules with small interfacesin 21 of 811, across 3 files

Said here and by no other author read

  • Use one typed Route enum for paths
  • Store all paths in a single router
  • Make routes carry IDs not model objects
  • Put navigationDestination at the stack root
  • Route all deep links through one handler
  • Encode modal presentation separately from routes

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 326,679. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.