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.
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
Hashableand represents all destinations. - Create a lightweight router (or use a library such as
https://github.com/Dimillian/AppRouter) that owns thepathand 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 awithAppRouter()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
NavigationStackper tab to preserve independent history. - A single source of truth for navigation state (
RouterPathor 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
@Observableobjects to avoid nested observation.