navigation-patterns
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 |
More from kaakati/rails-enterprise-dev
flutter conventions & best practices
Dart 3.x and Flutter 3.x conventions, naming patterns, code organization, null safety, and async/await best practices
55getx state management patterns
GetX controllers, reactive state, dependency injection, bindings, navigation, and best practices
52tailadmin ui patterns
TailAdmin dashboard UI framework patterns and Tailwind CSS classes. ALWAYS use this skill when: (1) Building any dashboard or admin panel interface, (2) Creating data tables, cards, charts, or metrics displays, (3) Implementing forms, buttons, alerts, or modals, (4) Building navigation (sidebar, header, breadcrumbs), (5) Any UI work that should follow TailAdmin design. This skill REQUIRES fetching from the official GitHub repository to ensure accurate class usage - NEVER invent classes.
39mvvm-architecture
Expert MVVM decisions for iOS/tvOS: choosing between ViewModel patterns (state enum vs published properties vs Combine), service layer boundaries, dependency injection strategies, and testing approaches. Use when designing ViewModel architecture, debugging data flow issues, or deciding where business logic belongs. Trigger keywords: MVVM, ViewModel, ObservableObject, @StateObject, service layer, dependency injection, unit test, mock, architecture
36rails localization (i18n) - english & arabic
Comprehensive internationalization skill for Ruby on Rails applications with proper English and Arabic translations, RTL support, pluralization rules, date/time formatting, and culturally appropriate content adaptation.
34rspec testing patterns
Complete guide to testing Ruby on Rails applications with RSpec. Use this skill when writing unit tests, integration tests, system tests, or when setting up test infrastructure including factories, shared examples, and mocking strategies.
31