Use when implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adopting coordinator patterns, or requesting code review of navigation implementation - prevents navigation state corruption, deep link failures, and state restoration bugs for iOS 18+
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
name: swiftui-nav description: Use when implementing navigation patterns, choosing between NavigationStack and NavigationSplitView, handling deep links, adopting coordinator patterns, or requesting code review of navigation implementation - prevents navigation state corruption, deep link failures, and state restoration bugs for iOS 18+ skill_type: discipline version: 1.0.0 last_updated: 2025-12-05 apple_platforms: iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass)
Use when:
swiftui-nav-diag for systematic troubleshooting of navigation failuresswiftui-nav-ref for comprehensive API reference (including Tab customization, iOS 26+ features) with all WWDC examplesThese are real questions developers ask that this skill is designed to answer:
-> The skill provides a decision tree based on device targets, content hierarchy depth, and multiplatform requirements
-> The skill shows NavigationPath manipulation patterns for push, pop, pop-to-root, and deep linking
-> The skill covers URL parsing patterns, path construction order, and timing issues with onOpenURL
-> The skill demonstrates Codable NavigationPath, SceneStorage persistence, and crash-resistant restoration
-> The skill provides Router pattern examples alongside guidance on when coordinators add value vs complexity
If you're doing ANY of these, STOP and use the patterns in this skill:
// ❌ WRONG — Deprecated, different behavior on iOS 16+
NavigationView {
List { ... }
}
.navigationViewStyle(.stack)
Why this fails NavigationView is deprecated since iOS 16. It lacks NavigationPath support, making programmatic navigation and deep linking unreliable. Different behavior across iOS versions causes bugs.
// ❌ WRONG — Cannot programmatically control
NavigationLink("Recipe") {
RecipeDetail(recipe: recipe) // View destination, no value
}
Why this fails View-based links cannot be controlled programmatically. No way to deep link or pop to this destination. Deprecated since iOS 16.
// ❌ WRONG — May not be loaded when needed
LazyVGrid(columns: columns) {
ForEach(items) { item in
NavigationLink(value: item) { ... }
.navigationDestination(for: Item.self) { item in // Don't do this
ItemDetail(item: item)
}
}
}
Why this fails Lazy containers don't load all views immediately. navigationDestination may not be visible to NavigationStack, causing navigation to silently fail.
// ❌ WRONG — Duplicates data, stale on restore
class NavigationModel: Codable {
var path: [Recipe] = [] // Full Recipe objects
}
Why this fails Duplicates data already in your model. On restore, Recipe data may be stale (edited/deleted elsewhere). Use IDs and resolve to current data.
// ❌ WRONG — UI update off main thread
Task.detached {
await viewModel.path.append(recipe) // Background thread
}
Why this fails NavigationPath binds to UI. Modifications must happen on MainActor or navigation state becomes corrupted. Can cause crashes or silent failures.
// ❌ WRONG — Not MainActor isolated
class Router: ObservableObject {
@Published var path = NavigationPath() // No @MainActor
}
Why this fails In Swift 6 strict concurrency, @Published properties accessed from SwiftUI views require MainActor isolation. Causes data race warnings and potential crashes.
// ❌ WRONG — Shared NavigationPath across tabs
TabView {
Tab("Home") { HomeView() }
Tab("Settings") { SettingsView() }
}
// All tabs share same NavigationStack — wrong!
Why this fails Each tab should have its own NavigationStack to preserve navigation state when switching tabs. Shared state causes confusing UX.
// ❌ WRONG — Crashes on invalid data
let path = NavigationPath(try! decoder.decode(NavigationPath.CodableRepresentation.self, from: data))
Why this fails User may have deleted items that were in the path. Schema may have changed. Force unwrap causes crash on restore.
ALWAYS complete these steps before implementing navigation:
// Step 1: Identify your navigation structure
// Ask: Single stack? Multi-column? Tab-based with per-tab navigation?
// Record answer before writing any code
// Step 2: Choose container based on structure
// Single stack (iPhone-primary): NavigationStack
// Multi-column (iPad/Mac-primary): NavigationSplitView
// Tab-based: TabView with NavigationStack per tab
// Step 3: Define your value types for navigation
// All values pushed on NavigationStack must be Hashable
// For deep linking/restoration, also Codable
struct Recipe: Hashable, Codable, Identifiable { ... }
// Step 4: Plan deep link URLs (if needed)
// myapp://recipe/{id}
// myapp://category/{name}/recipe/{id}
// Step 5: Plan state restoration (if needed)
// Will you use SceneStorage? What data must be Codable?
Need navigation?
├─ Multi-column interface (iPad/Mac primary)?
│ └─ NavigationSplitView
│ ├─ Need drill-down in detail column?
│ │ └─ NavigationStack inside detail (Pattern 3)
│ └─ Selection-only detail?
│ └─ Just selection binding (Pattern 2)
├─ Tab-based app?
│ └─ TabView
│ ├─ Each tab needs drill-down?
│ │ └─ NavigationStack per tab (Pattern 4)
│ └─ iPad sidebar experience?
│ └─ .tabViewStyle(.sidebarAdaptable) (Pattern 5)
└─ Single-column stack?
└─ NavigationStack
├─ Need deep linking?
│ └─ Use NavigationPath (Pattern 1b)
└─ Simple push/pop?
└─ Typed array path (Pattern 1a)
Need state restoration?
└─ SceneStorage + Codable NavigationPath (Pattern 6)
Need coordinator abstraction?
├─ Complex conditional flows?
├─ Navigation logic testing needed?
├─ Sharing navigation across many screens?
└─ YES to any → Router pattern (Pattern 7)
NO to all → Use NavigationPath directly
When: Simple push/pop navigation, all destinations same type
Time cost: 5-10 min
struct RecipeList: View {
@State private var path: [Recipe] = []
var body: some View {
NavigationStack(path: $path) {
List(recipes) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
// Programmatic navigation
func showRecipe(_ recipe: Recipe) {
path.append(recipe)
}
func popToRoot() {
path.removeAll()
}
}
Key points:
[Recipe] when all values are same typeNavigationLink(title, value:)navigationDestination(for:) outside lazy containersWhen: Multiple destination types, URL-based deep linking
Time cost: 15-20 min
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.onOpenURL { url in
handleDeepLink(url)
}
}
func handleDeepLink(_ url: URL) {
// URL: myapp://category/desserts/recipe/apple-pie
path.removeLast(path.count) // Pop to root first
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
let segments = components.path.split(separator: "/").map(String.init)
var index = 0
while index < segments.count - 1 {
switch segments[index] {
case "category":
if let category = Category(rawValue: segments[index + 1]) {
path.append(category)
}
index += 2
case "recipe":
if let recipe = dataModel.recipe(named: segments[index + 1]) {
path.append(recipe)
}
index += 2
default:
index += 1
}
}
}
}
Key points:
NavigationPath for heterogeneous typesWhen: Multi-column layout where detail shows selected item
Time cost: 10-15 min
struct MultiColumnView: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
} content: {
if let category = selectedCategory {
List(recipes(in: category), selection: $selectedRecipe) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(category.name)
} else {
Text("Select a category")
}
} detail: {
if let recipe = selectedRecipe {
RecipeDetail(recipe: recipe)
} else {
Text("Select a recipe")
}
}
}
}
Key points:
selection: $binding on List connects to column selectionWhen: Multi-column with drill-down capability in detail
Time cost: 20-25 min
struct GridWithDrillDown: View {
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $path) {
if let category = selectedCategory {
RecipeGrid(category: category)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
} else {
Text("Select a category")
}
}
}
}
}
Key points:
When: Tab-based app where each tab has its own navigation
Time cost: 15-20 min
struct TabBasedApp: View {
var body: some View {
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Tab("Search", systemImage: "magnifyingglass") {
NavigationStack {
SearchView()
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
}
}
Key points:
When: Tab bar on iPhone, sidebar on iPad
Time cost: 20-25 min
struct AdaptableApp: View {
var body: some View {
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
TabSection("Collections") {
Tab("Favorites", systemImage: "star") {
FavoritesView()
}
Tab("Recently Added", systemImage: "clock") {
RecentView()
}
}
Tab(role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
Key points:
.tabViewStyle(.sidebarAdaptable) enables sidebar on iPadTabSection creates collapsible groups in sidebarTab(role: .search) gets special placementWhen: Preserve navigation state across app launches
Time cost: 25-30 min
@MainActor
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe.ID] = [] // Store IDs, not objects
enum CodingKeys: String, CodingKey {
case selectedCategory, recipePath
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath, forKey: .recipePath)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)
recipePath = try container.decode([Recipe.ID].self, forKey: .recipePath)
}
init() {}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
selectedCategory = model.selectedCategory
recipePath = model.recipePath
}
}
}
struct ContentView: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationStack(path: $navModel.recipePath) {
// Content
}
.task {
if let data { navModel.jsonData = data }
for await _ in navModel.objectWillChange.values {
data = navModel.jsonData
}
}
}
}
Key points:
@MainActor for Swift 6 concurrency safetycompactMap when resolving IDs to handle deleted itemsWhen: Complex navigation logic, need testability
Time cost: 30-45 min
enum AppRoute: Hashable {
case home
case category(Category)
case recipe(Recipe)
case settings
}
@Observable
@MainActor
class Router {
var path = NavigationPath()
func navigate(to route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func showRecipeOfTheDay() {
popToRoot()
if let recipe = DataModel.shared.recipeOfTheDay {
path.append(AppRoute.recipe(recipe))
}
}
}
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home: HomeView()
case .category(let cat): CategoryView(category: cat)
case .recipe(let recipe): RecipeDetail(recipe: recipe)
case .settings: SettingsView()
}
}
}
.environment(router)
}
}
When coordinators add value:
When coordinators add complexity without value:
// ❌ WRONG — Nested stacks
NavigationStack {
SomeView()
.sheet(isPresented: $showSheet) {
NavigationStack { // Creates separate stack — confusing
SheetContent()
}
}
}
Issue Two navigation stacks create confusing UX. Back button behavior unclear. Fix Use single NavigationStack, present sheets without nested navigation when possible.
// ❌ WRONG — Double navigation triggers
Button("Go") {
// Some action
} label: {
NavigationLink(value: item) { // Fires on button AND link
Text("Item")
}
}
Issue Both Button and NavigationLink respond to taps.
Fix Use only NavigationLink, put action in .simultaneousGesture if needed.
// ❌ WRONG — Recreated every render
var body: some View {
let path = NavigationPath() // Reset on every render!
NavigationStack(path: .constant(path)) { ... }
}
Issue Path recreated each render, navigation state lost.
Fix Use @State or @StateObject for navigation state.
Product/design asks for complex navigation like Instagram:
If you hear ANY of these, STOP and evaluate:
"Let's list our actual navigation flows:
1. Home → Item Detail
2. Search → Results → Item Detail
3. Profile → Settings
That's 6 destinations. NavigationPath handles this natively."
"Here's our navigation with NavigationStack + NavigationPath:
[Show Pattern 1b code]
This gives us:
- Programmatic navigation ✓
- Deep linking ✓
- State restoration ✓
- Type safety ✓
Without a coordinator layer."
"If we find NavigationPath insufficient, we can add a Router
(Pattern 7) later. It's 30-45 minutes of work.
But let's start with the simpler solution and add complexity
only when we hit a real limitation."
Scenario:
Wrong approach:
Correct approach:
Team lead says: "Let's use NavigationView so we support iOS 15"
iOS 16+ adoption: 95%+ of active devices (as of 2024)
iOS 15: < 5% and declining
NavigationView limitations:
- No programmatic path manipulation
- No type-safe navigation
- No built-in state restoration
- Behavior varies by iOS version
"NavigationView was deprecated in iOS 16 (2022). Here's the impact:
1. We lose NavigationPath — can't implement deep linking reliably
2. Behavior differs between iOS 15 and 16 — more bugs to maintain
3. iOS 15 is < 5% of users — we're adding complexity for small audience
Recommendation: Set deployment target to iOS 16, use NavigationStack.
If iOS 15 support is required, use NavigationStack with @available
checks and fallback UI for older devices."
| Symptom | Likely Cause | Pattern |
|---|---|---|
| Navigation doesn't respond to taps | NavigationLink outside NavigationStack | Check hierarchy |
| Double navigation on tap | Button wrapping NavigationLink | Remove Button wrapper |
| State lost on tab switch | Shared NavigationStack across tabs | Pattern 4 |
| State lost on background | No SceneStorage | Pattern 6 |
| Deep link shows wrong screen | Path built in wrong order | Pattern 1b |
| Crash on restore | Force unwrap decode | Handle errors gracefully |
Last Updated Based on WWDC 2022-2025 navigation sessions Platforms iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+