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+
/plugin marketplace add CharlesWiltgen/Axiom/plugin install axiom@axiom-marketplaceThis skill inherits all available tools. When active, it can use any tool Claude has access to.
Use when:
axiom-swiftui-nav-diag for systematic troubleshooting of navigation failuresaxiom-swiftui-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 |
WWDC: 2022-10054, 2024-10147, 2025-256, 2025-323
Skills: axiom-swiftui-nav-diag, axiom-swiftui-nav-ref
Last Updated Based on WWDC 2022-2025 navigation sessions Platforms iOS 18+, iPadOS 18+, macOS 15+, watchOS 11+, tvOS 18+
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.