navigation-patterns
SKILL.md
Navigation Patterns — Expert Decisions
Expert decision frameworks for SwiftUI navigation choices. Claude knows NavigationStack syntax — this skill provides judgment calls for architecture decisions and state management trade-offs.
Decision Trees
Navigation Architecture Selection
How complex is your navigation?
├─ Simple (linear flows, 1-3 screens)
│ └─ NavigationStack with inline NavigationLink
│ No Router needed
│
├─ Medium (multiple flows, deep linking required)
│ └─ NavigationStack + Router (ObservableObject)
│ Centralized navigation state
│
└─ Complex (tabs with independent stacks, cross-tab navigation)
└─ Tab Coordinator + per-tab Routers
Each tab maintains own NavigationPath
NavigationPath vs Typed Array
Do you need heterogeneous routes?
├─ YES (different types in same stack)
│ └─ NavigationPath (type-erased)
│ path.append(User(...))
│ path.append(Product(...))
│
└─ NO (single route enum)
└─ @State var path: [Route] = []
Type-safe, debuggable, serializable
Rule: Prefer typed arrays unless you genuinely need mixed types. NavigationPath's type erasure makes debugging harder.
Deep Link Handling Strategy
When does deep link arrive?
├─ App already running (warm start)
│ └─ Direct navigation via Router
│
└─ App launches from deep link (cold start)
└─ Is view hierarchy ready?
├─ YES → Navigate immediately
└─ NO → Queue pending deep link
Handle in root view's .onAppear
Modal vs Push Selection
Is the destination a self-contained flow?
├─ YES (can complete/cancel independently)
│ └─ Modal (.sheet or .fullScreenCover)
│ Examples: Settings, Compose, Login
│
└─ NO (part of current navigation hierarchy)
└─ Push (NavigationLink or path.append)
Examples: Detail views, drill-down
NEVER Do
NavigationPath State
NEVER store NavigationPath in ViewModel without careful consideration:
// ❌ ViewModel owns navigation — couples business logic to navigation
@MainActor
final class HomeViewModel: ObservableObject {
@Published var path = NavigationPath() // Wrong layer!
}
// ✅ Router/Coordinator owns navigation, ViewModel owns data
@MainActor
final class Router: ObservableObject {
@Published var path = NavigationPath()
}
@MainActor
final class HomeViewModel: ObservableObject {
@Published var items: [Item] = [] // Data only
}
NEVER use NavigationPath across tabs:
// ❌ Shared path across tabs — navigation becomes unpredictable
struct MainTabView: View {
@StateObject var router = Router() // Single router!
var body: some View {
TabView {
// Both tabs share same path — chaos
}
}
}
// ✅ Each tab has independent navigation stack
struct MainTabView: View {
@StateObject var homeRouter = Router()
@StateObject var searchRouter = Router()
var body: some View {
TabView {
NavigationStack(path: $homeRouter.path) { ... }
NavigationStack(path: $searchRouter.path) { ... }
}
}
}
NEVER forget to handle deep links arriving before view hierarchy:
// ❌ Race condition — navigation may fail silently
@main
struct MyApp: App {
@StateObject var router = Router()
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
router.handle(url) // View may not exist yet!
}
}
}
}
// ✅ Queue deep link for deferred handling
@main
struct MyApp: App {
@StateObject var router = Router()
@State private var pendingDeepLink: URL?
var body: some Scene {
WindowGroup {
ContentView()
.onAppear {
if let url = pendingDeepLink {
router.handle(url)
pendingDeepLink = nil
}
}
.onOpenURL { url in
pendingDeepLink = url
}
}
}
}
Route Design
NEVER use stringly-typed routes:
// ❌ No compile-time safety, typos cause runtime failures
func navigate(to screen: String) {
switch screen {
case "profile": ...
case "setings": ... // Typo — silent failure
}
}
// ✅ Enum routes with associated values
enum Route: Hashable {
case profile(userId: String)
case settings
}
NEVER put navigation logic in Views:
// ❌ View knows too much about app structure
struct ItemRow: View {
var body: some View {
NavigationLink {
ItemDetailView(item: item) // View creates destination
} label: {
Text(item.name)
}
}
}
// ✅ Delegate navigation to Router
struct ItemRow: View {
@EnvironmentObject var router: Router
var body: some View {
Button(item.name) {
router.navigate(to: .itemDetail(item.id))
}
}
}
Navigation State Persistence
NEVER lose navigation state on app termination without consideration:
// ❌ User loses their place when app is killed
@StateObject var router = Router() // State lost on terminate
// ✅ Persist for important flows (optional based on UX needs)
@SceneStorage("navigationPath") private var pathData: Data?
var body: some View {
NavigationStack(path: $router.path) { ... }
.onAppear { router.restore(from: pathData) }
.onChange(of: router.path) { pathData = router.serialize() }
}
Essential Patterns
Type-Safe Router
@MainActor
final class Router: ObservableObject {
enum Route: Hashable {
case userList
case userDetail(userId: String)
case settings
case settingsSection(SettingsSection)
}
@Published var path: [Route] = []
func navigate(to route: Route) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeAll()
}
func replaceStack(with routes: [Route]) {
path = routes
}
@ViewBuilder
func destination(for route: Route) -> some View {
switch route {
case .userList:
UserListView()
case .userDetail(let userId):
UserDetailView(userId: userId)
case .settings:
SettingsView()
case .settingsSection(let section):
SettingsSectionView(section: section)
}
}
}
Deep Link Handler
enum DeepLink {
case user(id: String)
case product(id: String)
case settings
init?(url: URL) {
guard let scheme = url.scheme,
["myapp", "https"].contains(scheme) else { return nil }
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let components = path.components(separatedBy: "/")
switch components.first {
case "user":
guard components.count > 1 else { return nil }
self = .user(id: components[1])
case "product":
guard components.count > 1 else { return nil }
self = .product(id: components[1])
case "settings":
self = .settings
default:
return nil
}
}
}
extension Router {
func handle(_ deepLink: DeepLink) {
popToRoot()
switch deepLink {
case .user(let id):
navigate(to: .userList)
navigate(to: .userDetail(userId: id))
case .product(let id):
navigate(to: .productDetail(productId: id))
case .settings:
navigate(to: .settings)
}
}
}
Tab + Navigation Coordination
struct MainTabView: View {
@State private var selectedTab: Tab = .home
@StateObject private var homeRouter = Router()
@StateObject private var profileRouter = Router()
enum Tab { case home, search, profile }
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $homeRouter.path) {
HomeView()
.navigationDestination(for: Router.Route.self) { route in
homeRouter.destination(for: route)
}
}
.tag(Tab.home)
.environmentObject(homeRouter)
NavigationStack(path: $profileRouter.path) {
ProfileView()
.navigationDestination(for: Router.Route.self) { route in
profileRouter.destination(for: route)
}
}
.tag(Tab.profile)
.environmentObject(profileRouter)
}
}
// Pop to root on tab re-selection
func tabSelected(_ tab: Tab) {
if selectedTab == tab {
switch tab {
case .home: homeRouter.popToRoot()
case .profile: profileRouter.popToRoot()
case .search: break
}
}
selectedTab = tab
}
}
Quick Reference
Navigation Architecture Comparison
| Pattern | Complexity | Deep Link Support | Testability |
|---|---|---|---|
| Inline NavigationLink | Low | Manual | Low |
| Router with typed array | Medium | Good | High |
| NavigationPath | Medium | Good | Medium |
| Coordinator Pattern | High | Excellent | Excellent |
When to Use Each Modal Type
| Modal Type | Use For |
|---|---|
.sheet |
Secondary tasks, can dismiss |
.fullScreenCover |
Immersive flows (onboarding, login) |
.alert |
Critical decisions |
.confirmationDialog |
Action choices |
Red Flags
| Smell | Problem | Fix |
|---|---|---|
| NavigationPath across tabs | State confusion | Per-tab routers |
| View creates destination directly | Tight coupling | Router pattern |
| String-based routing | No compile safety | Enum routes |
| Deep link ignored on cold start | Race condition | Pending URL queue |
| ViewModel owns NavigationPath | Layer violation | Router owns navigation |
| No popToRoot on tab re-tap | UX expectation | Handle tab selection |
Weekly Installs
13
Repository
kaakati/rails-e…rise-devGitHub Stars
6
First Seen
Jan 25, 2026
Security Audits
Installed on
claude-code11
opencode11
codex10
gemini-cli10
antigravity9
github-copilot9