home / skills / kaakati / rails-enterprise-dev / navigation-patterns
This skill guides you in selecting SwiftUI navigation architectures, managing navigation state, and implementing robust deep links for scalable iOS apps.
npx playbooks add skill kaakati/rails-enterprise-dev --skill navigation-patternsReview the files below or copy the command above to add this skill to your agents.
---
name: navigation-patterns
description: "Expert navigation decisions for iOS/tvOS: when NavigationStack vs Coordinator patterns, NavigationPath state management trade-offs, deep link architecture choices, and tab+navigation coordination strategies. Use when designing app navigation, implementing deep links, or debugging navigation state issues. Trigger keywords: NavigationStack, NavigationPath, deep link, routing, tab bar, navigation, programmatic navigation, universal link, URL scheme, navigation state"
version: "3.0.0"
---
# 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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:
```swift
// ❌ 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
```swift
@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
```swift
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
```swift
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 |
This skill advises on choosing and implementing navigation patterns for iOS and tvOS apps, focusing on NavigationStack vs Coordinator, NavigationPath trade-offs, deep link handling, and tab + navigation coordination. It delivers practical rules, decision trees, and code-guided guidance to design predictable, testable navigation. Use it when designing app navigation architecture, implementing deep links, or debugging navigation-state issues.
The skill inspects your app's navigation complexity and recommends patterns: inline NavigationLink for simple linear flows, a typed Router with a typed array for medium complexity, and a tab-coordinator with per-tab routers for complex multi-stack apps. It evaluates whether you need type-erased NavigationPath or type-safe route enums, how to queue and handle deep links across cold and warm starts, and when to present modals vs push navigation. It also highlights anti-patterns and provides safe defaults for state ownership and persistence.
When should I use NavigationPath instead of a typed route array?
Use NavigationPath only if you must store heterogeneous types in one stack. Prefer typed arrays for compile-time safety, easier debugging, and serialization.
Where should deep link logic live?
Centralize parsing and handling in a Router or DeepLink handler. Queue links on cold start and apply them when the root view hierarchy is ready.