gh-Dimillian-Skills/swiftui-ui-patterns/references/navigationstack.md
Thomas Ricouard 70a15d08db Add SwiftUI UI patterns skill and references
Introduces the 'swiftui-ui-patterns' skill to docs/skills.json, providing best practices and example-driven guidance for building SwiftUI views and components. Adds SKILL.md and a comprehensive set of reference files covering TabView, NavigationStack, sheets, forms, controls, grids, overlays, haptics, focus handling, media, matched transitions, split views, and more.
2026-01-04 18:26:56 +01:00

4.2 KiB

NavigationStack

Intent

Use this pattern for programmatic navigation and deep links, especially when each tab needs an independent navigation history. The key idea is one NavigationStack per tab, each with its own path binding and router object.

Core architecture

  • Define a route enum that is Hashable and represents all destinations.
  • Create a lightweight router (or use a library such as https://github.com/Dimillian/AppRouter) that owns the path and any sheet state.
  • Each tab owns its own router instance and binds NavigationStack(path:) to it.
  • Inject the router into the environment so child views can navigate programmatically.
  • Centralize destination mapping with a single navigationDestination(for:) block (or a withAppRouter() modifier).

Example: custom router with per-tab stack

@MainActor
@Observable
final class RouterPath {
  var path: [Route] = []
  var presentedSheet: SheetDestination?

  func navigate(to route: Route) {
    path.append(route)
  }

  func reset() {
    path = []
  }
}

enum Route: Hashable {
  case account(id: String)
  case status(id: String)
}

@MainActor
struct TimelineTab: View {
  @State private var routerPath = RouterPath()

  var body: some View {
    NavigationStack(path: $routerPath.path) {
      TimelineView()
        .navigationDestination(for: Route.self) { route in
          switch route {
          case .account(let id): AccountView(id: id)
          case .status(let id): StatusView(id: id)
          }
        }
    }
    .environment(routerPath)
  }
}

Example: centralized destination mapping

Use a shared view modifier to avoid duplicating route switches across screens.

extension View {
  func withAppRouter() -> some View {
    navigationDestination(for: Route.self) { route in
      switch route {
      case .account(let id):
        AccountView(id: id)
      case .status(let id):
        StatusView(id: id)
      }
    }
  }
}

Then apply it once per stack:

NavigationStack(path: $routerPath.path) {
  TimelineView()
    .withAppRouter()
}

Example: binding per tab (tabs with independent history)

@MainActor
struct TabsView: View {
  @State private var timelineRouter = RouterPath()
  @State private var notificationsRouter = RouterPath()

  var body: some View {
    TabView {
      TimelineTab(router: timelineRouter)
      NotificationsTab(router: notificationsRouter)
    }
  }
}

Example: generic tabs with per-tab NavigationStack

Use this when tabs are built from data and each needs its own path without hard-coded names.

@MainActor
struct TabsView: View {
  @State private var selectedTab: AppTab = .timeline
  @State private var tabRouter = TabRouter()

  var body: some View {
    TabView(selection: $selectedTab) {
      ForEach(AppTab.allCases) { tab in
        NavigationStack(path: tabRouter.binding(for: tab)) {
          tab.makeContentView()
        }
        .environment(tabRouter.router(for: tab))
        .tabItem { tab.label }
        .tag(tab)
      }
    }
  }
}

@MainActor @Observable final class TabRouter { private var routers: [AppTab: RouterPath] = [:]

func router(for tab: AppTab) -> RouterPath { if let router = routers[tab] { return router } let router = RouterPath() routers[tab] = router return router }

func binding(for tab: AppTab) -> Binding<[Route]> { let router = router(for: tab) return Binding(get: { router.path }, set: { router.path = $0 }) } }

Design choices to keep

  • One NavigationStack per tab to preserve independent history.
  • A single source of truth for navigation state (RouterPath or library router).
  • Use navigationDestination(for:) to map routes to views.
  • Reset the path when app context changes (account switch, logout, etc.).
  • Inject the router into the environment so child views can navigate and present sheets without prop-drilling.
  • Keep sheet presentation state on the router if you want a single place to manage modals.

Pitfalls

  • Do not share one path across all tabs unless you want global history.
  • Ensure route identifiers are stable and Hashable.
  • Avoid storing view instances in the path; store lightweight route data instead.
  • If using a router object, keep it outside other @Observable objects to avoid nested observation.