
Navigation Patterns
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Reference for SwiftUI navigation architecture: NavigationStack, NavigationSplitView, TabView, programmatic navigation, deep linking, and custom transitions.
About
A guide to modern SwiftUI navigation architecture covering NavigationStack, NavigationSplitView, TabView, NavigationPath, and programmatic navigation. A developer uses it when building navigation, fixing navigation bugs, or architecting app flow.
- Covers NavigationStack, SplitView, and NavigationPath
- Programmatic navigation and deep-linking patterns
Navigation Patterns by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill navigation-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Reference for SwiftUI navigation architecture: NavigationStack, NavigationSplitView, TabView, programmatic navigation, deep linking, and custom transitions.
Files
Navigation Patterns
Comprehensive guide for SwiftUI navigation architecture on iOS, iPadOS, and macOS. Covers the modern navigation APIs (iOS 16+/macOS 13+) with patterns for common and advanced use cases.
When This Skill Activates
- User is building or reviewing navigation architecture
- User has navigation-related bugs (stack not updating, back button issues, state loss)
- User asks about NavigationStack, NavigationSplitView, TabView, or NavigationPath
- User needs programmatic navigation (push, pop, pop-to-root)
- User is implementing deep linking that connects to navigation
- User asks about navigation transitions or animations
- User is choosing between navigation approaches for their app
Decision Tree
Use this to pick the right navigation container:
What is the app structure?
│
├─ Flat sections (3-5 top-level areas)
│ └─ TabView → see tab-view.md
│
├─ Hierarchical drill-down (list → detail)
│ └─ NavigationStack → see navigation-stack.md
│
├─ Sidebar + content (macOS / iPad)
│ ├─ Two columns → NavigationSplitView → see navigation-split-view.md
│ └─ Three columns → NavigationSplitView → see navigation-split-view.md
│
└─ Combined (tabs with drill-down, sidebar with stacks)
└─ TabView + NavigationStack per tab
OR NavigationSplitView + NavigationStack in detailQuick Reference
| Pattern | Container | Min OS | Reference |
|---|---|---|---|
| Simple drill-down | NavigationStack | iOS 16 | navigation-stack.md |
| Value-based links | NavigationLink(value:) | iOS 16 | navigation-stack.md |
| Programmatic push/pop | NavigationPath | iOS 16 | programmatic-navigation.md |
| Pop to root | path = NavigationPath() | iOS 16 | programmatic-navigation.md |
| State restoration | NavigationPath.CodableRepresentation | iOS 16 | programmatic-navigation.md |
| Two-column layout | NavigationSplitView | iOS 16 | navigation-split-view.md |
| Three-column layout | NavigationSplitView | iOS 16 | navigation-split-view.md |
| Column visibility | NavigationSplitViewVisibility | iOS 16 | navigation-split-view.md |
| Tab bar | TabView | iOS 13 | tab-view.md |
| Customizable tabs | Tab + TabView | iOS 18 | tab-view.md |
| Sidebar tabs (iPad) | .tabViewStyle(.sidebarAdaptable) | iOS 18 | tab-view.md |
| Zoom transition | .navigationTransition(.zoom) | iOS 18 | navigation-transitions.md |
| Custom transitions | NavigationTransition | iOS 18 | navigation-transitions.md |
Process
1. Identify Navigation Needs
Read the user's code or requirements to determine:
- App structure (flat, hierarchical, sidebar-based)
- Target platforms (iOS only, iPad adaptive, macOS)
- Whether programmatic navigation is needed
- Deep linking requirements
2. Load Relevant Reference Files
Based on the need, read from this directory:
navigation-stack.md— NavigationStack, NavigationLink, navigationDestinationnavigation-split-view.md— Two/three column layouts, column control, adaptive behaviortab-view.md— TabView, iOS 18 customizable tabs, sidebar modeprogrammatic-navigation.md— NavigationPath, state restoration, coordinators, pop-to-rootnavigation-transitions.md— Custom push/pop transitions (iOS 18+)
3. Review or Recommend
Apply patterns from the reference files. Check for common mistakes:
- [ ] Using deprecated
NavigationViewinstead ofNavigationStack/NavigationSplitView - [ ] Using
NavigationLink(destination:)instead ofNavigationLink(value:)+.navigationDestination - [ ] Placing
NavigationStackinsideNavigationSplitViewdetail (usually wrong) - [ ] Missing
.navigationDestinationregistration for a value type - [ ] NavigationPath not
@Stateor not in the right scope - [ ] Multiple NavigationStacks competing for the same navigation context
- [ ] Hard-coding navigation instead of using
NavigationPathfor programmatic control - [ ] Not handling deep links through the navigation system
4. Cross-Reference
- For deep linking URL handling, see
generators/deep-linking/skill - For navigation animations, see
design/animation-patterns/transitions.md - For macOS sidebar patterns, see
macos/ui-review-tahoe/swiftui-macos.md
References
NavigationSplitView Patterns
Multi-column navigation for iPad and macOS. Automatically adapts to compact size classes (iPhone) by collapsing into a single NavigationStack-like experience.
Two-Column Layout
struct ContentView: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) {
Label(item.name, systemImage: item.icon)
}
}
.navigationTitle("Items")
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
.id(item.id) // Force recreation when selection changes
} else {
ContentUnavailableView("Select an Item",
systemImage: "doc",
description: Text("Choose an item from the sidebar"))
}
}
}
}Key points:
- The sidebar uses
List(selection:)to bind selection - Detail column shows a placeholder when nothing is selected
.id(item.id)forces SwiftUI to recreate the detail view on selection change
Three-Column Layout
struct ContentView: View {
@State private var selectedCategory: Category?
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Column 1: Sidebar
List(categories, selection: $selectedCategory) { category in
NavigationLink(value: category) {
Label(category.name, systemImage: category.icon)
}
}
.navigationTitle("Categories")
} content: {
// Column 2: Content
if let category = selectedCategory {
List(category.items, selection: $selectedItem) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationTitle(category.name)
} else {
ContentUnavailableView("Select a Category",
systemImage: "folder")
}
} detail: {
// Column 3: Detail
if let item = selectedItem {
ItemDetailView(item: item)
.id(item.id)
} else {
ContentUnavailableView("Select an Item",
systemImage: "doc")
}
}
}
}Column Width Control
NavigationSplitView {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 300)
} content: {
ContentListView()
.navigationSplitViewColumnWidth(min: 200, ideal: 300, max: 400)
} detail: {
DetailView()
.navigationSplitViewColumnWidth(min: 300, ideal: 500)
}Use ideal width only for a fixed-width column:
.navigationSplitViewColumnWidth(250) // Fixed widthColumn Visibility
Control which columns are visible programmatically:
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
} content: {
ContentListView()
} detail: {
DetailView()
}Visibility options:
| Value | Behavior |
|---|---|
.all | Show all columns |
.doubleColumn | Show content + detail (hide sidebar) |
.detailOnly | Show only detail column |
.automatic | System decides based on size class |
Toggle sidebar visibility:
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Toggle Sidebar") {
withAnimation {
columnVisibility = columnVisibility == .all ? .detailOnly : .all
}
}
}
}Split View Style
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.navigationSplitViewStyle(.balanced) // Sidebar and detail share space
// .navigationSplitViewStyle(.prominentDetail) // Detail takes priority
// .navigationSplitViewStyle(.automatic) // System decidesNavigationStack Inside Detail
When the detail column needs its own drill-down navigation:
NavigationSplitView {
List(categories, selection: $selectedCategory) { category in
NavigationLink(value: category) {
Text(category.name)
}
}
} detail: {
if let category = selectedCategory {
// NavigationStack inside detail for drill-down
NavigationStack {
List(category.items) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationTitle(category.name)
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
} else {
ContentUnavailableView("Select a Category", systemImage: "folder")
}
}When to Use NavigationStack Inside Detail
- Detail column needs hierarchical navigation (list → detail → sub-detail)
- Detail column needs programmatic push/pop
When NOT to Use NavigationStack Inside Detail
// ❌ Unnecessary NavigationStack — detail has no drill-down
NavigationSplitView {
SidebarView()
} detail: {
NavigationStack {
SimpleDetailView(item: selected) // No NavigationLinks here
}
}
// ✅ Just show the detail view directly
NavigationSplitView {
SidebarView()
} detail: {
SimpleDetailView(item: selected)
}Compact Adaptation (iPhone)
NavigationSplitView automatically collapses to a single-column NavigationStack-like experience on compact width (iPhone). The sidebar becomes the root and columns stack as push destinations.
Control the preferred compact column:
@State private var preferredColumn: NavigationSplitViewColumn = .sidebar
NavigationSplitView(preferredCompactColumn: $preferredColumn) {
SidebarView()
} detail: {
DetailView()
}This controls which column is shown by default on compact. Setting .detail shows the detail view immediately on iPhone.
Common Mistakes
Putting NavigationStack Around NavigationSplitView
// ❌ Double navigation bars, broken layout
NavigationStack {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
}
// ✅ NavigationSplitView is a top-level container
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}Not Handling Empty Selection
// ❌ Force unwrap or ignoring nil selection
NavigationSplitView {
SidebarView()
} detail: {
DetailView(item: selectedItem!) // Crash when nil
}
// ✅ Always provide a placeholder
NavigationSplitView {
SidebarView()
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView("No Selection", systemImage: "sidebar.left")
}
}Not Using .id() on Detail View
// ❌ Detail view keeps stale state when selection changes
} detail: {
if let item = selectedItem {
ItemDetailView(item: item) // @State inside won't reset
}
}
// ✅ Force recreation with .id()
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
.id(item.id)
}
}Why? SwiftUI may reuse the same view instance when the selection changes. Internal @State properties won't reset unless the view identity changes.
Checklist
- [ ] Using
NavigationSplitView(not deprecatedNavigationView(.columns)) - [ ] Handling
nilselection withContentUnavailableView - [ ] Using
.id()on detail views to force state reset - [ ] Column widths set with
navigationSplitViewColumnWidth - [ ] Only wrapping detail in
NavigationStackwhen drill-down is needed - [ ] Testing compact (iPhone) behavior for adaptive layouts
- [ ] Not nesting
NavigationSplitViewinsideNavigationStack
NavigationStack Patterns
The primary navigation container for hierarchical drill-down interfaces. Replaced NavigationView in iOS 16.
Core Pattern
struct ContentView: View {
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
}
}Key elements:
NavigationStackwraps the root viewNavigationLink(value:)pushes a value onto the stack.navigationDestination(for:)maps value types to destination views- Destination registration can be on any view inside the stack
NavigationLink
Value-Based Links (Modern)
// ✅ Modern — type-safe, works with programmatic navigation
NavigationLink(value: item) {
Label(item.name, systemImage: item.icon)
}
// Register the destination once
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}Destination-Based Links (Legacy)
// ❌ Legacy — no programmatic navigation support, eager view creation
NavigationLink(destination: ItemDetailView(item: item)) {
Label(item.name, systemImage: item.icon)
}Why? Destination-based links create the destination view immediately (even before tap). Value-based links defer view creation and integrate with NavigationPath.
Multiple Destination Types
Register multiple .navigationDestination modifiers for different types:
NavigationStack {
List {
Section("People") {
ForEach(people) { person in
NavigationLink(value: person) {
PersonRow(person: person)
}
}
}
Section("Places") {
ForEach(places) { place in
NavigationLink(value: place) {
PlaceRow(place: place)
}
}
}
}
.navigationDestination(for: Person.self) { person in
PersonDetailView(person: person)
}
.navigationDestination(for: Place.self) { place in
PlaceDetailView(place: place)
}
}Nested Navigation (Multi-Level Drill-Down)
Each pushed view can have its own NavigationLink values. They all share the same stack:
struct CategoryView: View {
let category: Category
var body: some View {
List(category.items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle(category.name)
}
}No need for nested NavigationStack — there is one stack at the root and all pushed views participate in it.
Common Mistakes
Nesting NavigationStacks
// ❌ Double navigation bar, broken back button
NavigationStack {
NavigationStack {
Text("Hello")
}
}
// ❌ NavigationStack inside a pushed view
struct DetailView: View {
var body: some View {
NavigationStack { // Wrong — creates a second stack
Text("Detail")
}
}
}
// ✅ One NavigationStack at the root, pushed views are plain
struct DetailView: View {
var body: some View {
Text("Detail")
.navigationTitle("Detail")
}
}Missing navigationDestination
// ❌ NavigationLink pushes a value but no destination registered — tap does nothing
NavigationStack {
NavigationLink(value: item) { Text(item.name) }
// Forgot .navigationDestination(for: Item.self) { ... }
}navigationDestination Outside NavigationStack
// ❌ Destination registered outside the stack — never matched
VStack {
NavigationStack {
NavigationLink(value: item) { Text(item.name) }
}
.navigationDestination(for: Item.self) { item in // Outside stack
DetailView(item: item)
}
}
// ✅ Register inside the stack
NavigationStack {
NavigationLink(value: item) { Text(item.name) }
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}Duplicate Destination Registrations
// ❌ Two registrations for same type — undefined behavior
NavigationStack {
content
.navigationDestination(for: Item.self) { item in ViewA(item: item) }
.navigationDestination(for: Item.self) { item in ViewB(item: item) }
}
// ✅ One registration per type, use an enum for multiple routes
enum Route: Hashable {
case viewA(Item)
case viewB(Item)
}
NavigationStack {
content
.navigationDestination(for: Route.self) { route in
switch route {
case .viewA(let item): ViewA(item: item)
case .viewB(let item): ViewB(item: item)
}
}
}navigationDestination with isPresented
For conditional navigation (push based on a boolean):
@State private var showSettings = false
NavigationStack {
Button("Settings") { showSettings = true }
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
}This pushes SettingsView when showSettings becomes true and pops it when it becomes false.
NavigationStack with Explicit Path
For programmatic control, pass a NavigationPath or typed array:
// Type-erased path (supports mixed types)
@State private var path = NavigationPath()
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Item.self) { item in
ItemView(item: item)
}
.navigationDestination(for: Category.self) { cat in
CategoryView(category: cat)
}
}
// Typed path (single type only)
@State private var path: [Item] = []
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Item.self) { item in
ItemView(item: item)
}
}See programmatic-navigation.md for full programmatic navigation patterns.
Toolbar and Navigation Bar
NavigationStack {
ContentView()
.navigationTitle("Home")
.navigationBarTitleDisplayMode(.large) // .large, .inline, .automatic
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Add", systemImage: "plus") { }
}
ToolbarItem(placement: .topBarLeading) {
EditButton()
}
}
.toolbarRole(.editor) // Hides back button title, shows only chevron
}Searchable Navigation
NavigationStack {
List(filteredItems) { item in
NavigationLink(value: item) { ItemRow(item: item) }
}
.searchable(text: $searchText, prompt: "Search items")
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}Checklist
- [ ] Using
NavigationStack(not deprecatedNavigationView) - [ ] Using
NavigationLink(value:)with.navigationDestination(for:) - [ ] No nested
NavigationStackin pushed views - [ ] One
.navigationDestinationper type per stack - [ ] Destination registered inside the
NavigationStackscope - [ ] Using
NavigationPathif programmatic navigation is needed - [ ] Navigation title set on inner views (not on
NavigationStackitself)
Navigation Transitions
Custom push/pop transition animations for NavigationStack. Available on iOS 18+/macOS 15+.
Built-in Transitions
Zoom Transition (iOS 18+)
Creates a zoom effect from a source view to the pushed destination:
struct ListView: View {
@Namespace private var namespace
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}
}Key components:
@Namespacecreates a shared animation namespace.matchedTransitionSource(id:in:)marks the source element.navigationTransition(.zoom(sourceID:in:))on the destination view
The zoom transition animates from the source cell to a full-screen destination, and reverses on pop.
Slide Transition
The default push/pop slide animation:
.navigationTransition(.slide)This is the system default — you only need to specify it explicitly if overriding a different transition.
Namespace Best Practices
One Namespace Per List
// ✅ Single namespace, unique IDs per item
@Namespace private var namespace
ForEach(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}
}Namespace with Sections
@Namespace private var namespace
// ✅ IDs must be unique across all sections in the same namespace
Section("Recent") {
ForEach(recentItems) { item in
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: "recent-\(item.id)", in: namespace)
}
}
}
Section("All") {
ForEach(allItems) { item in
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: "all-\(item.id)", in: namespace)
}
}
}matchedTransitionSource vs matchedGeometryEffect
These solve different problems:
| API | Purpose | Context |
|---|---|---|
matchedTransitionSource + navigationTransition(.zoom) | Navigation push/pop animation | Between NavigationStack pages |
matchedGeometryEffect | Shared element animation within a single view | Within the same view hierarchy (e.g., hero animation in a layout change) |
// ❌ matchedGeometryEffect does NOT work for NavigationStack transitions
NavigationLink(value: item) {
ItemRow(item: item)
.matchedGeometryEffect(id: item.id, in: namespace) // Wrong API
}
// ✅ Use matchedTransitionSource for navigation
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}Grid to Detail Zoom
Common pattern for photo grids or card layouts:
struct PhotoGrid: View {
@Namespace private var namespace
let photos: [Photo]
private let columns = [GridItem(.adaptive(minimum: 100))]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
PhotoThumbnail(photo: photo)
.matchedTransitionSource(id: photo.id, in: namespace)
}
.buttonStyle(.plain)
}
}
}
.navigationTitle("Photos")
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: namespace))
}
}
}
}Conditional Transitions
Apply different transitions based on content:
.navigationDestination(for: Route.self) { route in
switch route {
case .photo(let photo):
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: namespace))
case .settings:
SettingsView()
// No custom transition — uses default slide
}
}Common Mistakes
Mismatched IDs
// ❌ Source and destination use different IDs — no animation
.matchedTransitionSource(id: item.id, in: namespace)
// ...
.navigationTransition(.zoom(sourceID: "item-\(item.id)", in: namespace))
// ✅ Same ID value in both
.matchedTransitionSource(id: item.id, in: namespace)
// ...
.navigationTransition(.zoom(sourceID: item.id, in: namespace))Different Namespaces
// ❌ Source and destination use different namespaces
@Namespace private var sourceNamespace
@Namespace private var destNamespace
.matchedTransitionSource(id: item.id, in: sourceNamespace)
.navigationTransition(.zoom(sourceID: item.id, in: destNamespace))
// ✅ Same namespace for source and destination
@Namespace private var namespace
.matchedTransitionSource(id: item.id, in: namespace)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))Namespace Declared in Wrong Scope
// ❌ Namespace in child view — destroyed when child is popped
struct ChildView: View {
@Namespace private var namespace // Dies with this view
var body: some View {
NavigationLink(value: item) {
ItemRow(item: item)
.matchedTransitionSource(id: item.id, in: namespace)
}
}
}
// ✅ Namespace in the view that owns the NavigationStack or its direct children
struct ParentView: View {
@Namespace private var namespace
var body: some View {
NavigationStack {
ItemList(namespace: namespace)
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}
}Checklist
- [ ] Using
matchedTransitionSource(notmatchedGeometryEffect) for navigation transitions - [ ] Source and destination use identical IDs and the same
@Namespace - [ ]
@Namespacedeclared in a view that persists across the transition - [ ] IDs are unique across the entire list/grid
- [ ]
.buttonStyle(.plain)onNavigationLinkin grids to avoid highlight artifacts - [ ] Fallback behavior considered for iOS < 18 (zoom unavailable, default slide used)
Programmatic Navigation
Patterns for controlling navigation state in code: pushing, popping, deep linking coordination, and state restoration.
NavigationPath
NavigationPath is a type-erased stack of navigation values. It supports mixed types and provides the core API for programmatic navigation.
@State private var path = NavigationPath()
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
}Push
path.append(item) // Push Item view
path.append(category) // Push Category view (mixed types)Pop
path.removeLast() // Pop one level
path.removeLast(2) // Pop two levelsPop to Root
path = NavigationPath() // Clear entire stack — returns to rootCheck Stack
path.isEmpty // True if at root
path.count // Number of items on stackTyped Path (Single Type)
When all navigation destinations are the same type, use a plain array:
@State private var path: [Item] = []
NavigationStack(path: $path) {
ListView()
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
// Push
path.append(item)
// Pop to root
path.removeAll()
// Build a specific stack (e.g., from deep link)
path = [parentItem, childItem, grandchildItem]Advantage: Direct array access, subscripting, filtering. Use when you only navigate to one type.
Navigation Coordinator
Centralize navigation logic in an @Observable class:
@Observable
final class NavigationCoordinator {
var path = NavigationPath()
func navigate(to item: Item) {
path.append(item)
}
func navigate(to category: Category) {
path.append(category)
}
func popToRoot() {
path = NavigationPath()
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
// Deep link handling
func handle(deepLink: DeepLink) {
popToRoot()
switch deepLink {
case .item(let id):
if let item = ItemStore.shared.item(for: id) {
path.append(item)
}
case .category(let id):
if let category = CategoryStore.shared.category(for: id) {
path.append(category)
}
}
}
}Usage:
struct AppView: View {
@State private var coordinator = NavigationCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
}
.environment(coordinator)
.onOpenURL { url in
if let deepLink = DeepLink(url: url) {
coordinator.handle(deepLink: deepLink)
}
}
}
}
// Any child view can navigate programmatically
struct SomeChildView: View {
@Environment(NavigationCoordinator.self) private var coordinator
var body: some View {
Button("Go to item") {
coordinator.navigate(to: item)
}
}
}Tab Navigation + Per-Tab Paths
For apps with tabs where each tab has independent navigation:
@Observable
final class AppRouter {
var selectedTab: AppTab = .home
var homePath = NavigationPath()
var searchPath = NavigationPath()
var profilePath: [ProfileDestination] = []
func switchTab(_ tab: AppTab, resettingNavigation: Bool = false) {
if selectedTab == tab && !homePath(for: tab).isEmpty {
// Tapping active tab pops to root (standard iOS behavior)
popToRoot(for: tab)
} else {
selectedTab = tab
}
if resettingNavigation {
popToRoot(for: tab)
}
}
func popToRoot(for tab: AppTab) {
switch tab {
case .home: homePath = NavigationPath()
case .search: searchPath = NavigationPath()
case .profile: profilePath.removeAll()
}
}
}State Restoration
Save and restore navigation state across app launches using NavigationPath.CodableRepresentation:
@Observable
final class NavigationCoordinator {
var path = NavigationPath()
private static let savedPathKey = "savedNavigationPath"
var codableRepresentation: NavigationPath.CodableRepresentation? {
path.codable
}
func save() {
guard let representation = path.codable else { return }
let encoder = JSONEncoder()
if let data = try? encoder.encode(representation) {
UserDefaults.standard.set(data, forKey: Self.savedPathKey)
}
}
func restore() {
guard let data = UserDefaults.standard.data(forKey: Self.savedPathKey),
let representation = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data
) else { return }
path = NavigationPath(representation)
}
}Requirements: All value types pushed onto the path must conform to Codable (in addition to Hashable).
// ✅ Works — conforms to both Hashable and Codable
struct Item: Hashable, Codable {
let id: UUID
let name: String
}
// ❌ Won't compile for state restoration — missing Codable
struct Item: Hashable {
let id: UUID
let name: String
}Save on scene phase change:
struct AppView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var coordinator = NavigationCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
coordinator.save()
}
}
.onAppear {
coordinator.restore()
}
}
}Deep Link Coordination
Connect URL-based deep links to programmatic navigation:
enum DeepLink {
case item(id: UUID)
case category(id: UUID)
case profile(username: String)
case settings
init?(url: URL) {
// Handle custom scheme: myapp://item/UUID
// Handle universal link: https://example.com/item/UUID
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return nil
}
let pathParts = components.path.split(separator: "/").map(String.init)
switch pathParts.first {
case "item":
guard let idString = pathParts.dropFirst().first,
let id = UUID(uuidString: idString) else { return nil }
self = .item(id: id)
case "category":
guard let idString = pathParts.dropFirst().first,
let id = UUID(uuidString: idString) else { return nil }
self = .category(id: id)
case "profile":
guard let username = pathParts.dropFirst().first else { return nil }
self = .profile(username: username)
case "settings":
self = .settings
default:
return nil
}
}
}Handle in the coordinator:
.onOpenURL { url in
if let deepLink = DeepLink(url: url) {
coordinator.handle(deepLink: deepLink)
}
}For full deep link infrastructure (URL schemes, Universal Links, AASA files), see the generators/deep-linking/ skill.
Passing Data Back (Pop with Result)
SwiftUI has no built-in "pop with result" API. Common patterns:
Closure Callback
struct PickerView: View {
let onSelect: (Item) -> Void
var body: some View {
List(items) { item in
Button(item.name) {
onSelect(item)
}
}
}
}
// In the calling view
.navigationDestination(for: PickerRoute.self) { _ in
PickerView { selectedItem in
self.selectedItem = selectedItem
path.removeLast() // Pop after selection
}
}Environment / Observable Shared State
@Observable
final class SelectionState {
var pickedItem: Item?
}
// Parent sets up state, child writes to it
// Parent observes changes via onChangeCommon Mistakes
Mutating Path During View Update
// ❌ Causes "Modifying state during view update" warning
var body: some View {
if someCondition {
path.append(item) // Don't mutate state in body
}
}
// ✅ Mutate in response to actions or onChange
.onChange(of: someCondition) { _, newValue in
if newValue {
path.append(item)
}
}Not Resetting Path on Deep Link
// ❌ Deep link pushes on top of existing stack — confusing
func handle(deepLink: DeepLink) {
path.append(deepLink.destination)
}
// ✅ Clear stack first, then navigate
func handle(deepLink: DeepLink) {
path = NavigationPath() // Pop to root
path.append(deepLink.destination) // Navigate to target
}Storing NavigationPath in @Observable Without @State
// ❌ Path doesn't trigger view updates when not @State
struct ContentView: View {
let path = NavigationPath()
}
// ✅ Path must be @State (or in an @Observable class used via @State)
@State private var path = NavigationPath()
// ✅ Or in an @Observable coordinator injected as @State
@State private var coordinator = NavigationCoordinator()
NavigationStack(path: $coordinator.path) { }Checklist
- [ ]
NavigationPathis@Stateor inside an@Observableclass held by@State - [ ] Pop-to-root implemented (clear path) for tab re-tap or deep links
- [ ] Deep links reset the navigation stack before pushing
- [ ] All path value types conform to
Hashable(andCodableif state restoration needed) - [ ] State saved on
scenePhase == .backgroundif restoration needed - [ ] Navigation mutations happen in action closures, not in
body - [ ] Navigation coordinator injected via
.environment()for child view access
TabView Patterns
Tab-based navigation for organizing top-level app sections. iOS 18 introduced major new capabilities including customizable tab bars and sidebar-adaptive layouts.
Basic TabView (iOS 16+)
struct ContentView: View {
var body: some View {
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SearchView()
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
ProfileView()
.tabItem {
Label("Profile", systemImage: "person")
}
}
}
}Modern TabView with Tab (iOS 18+)
iOS 18 introduced the Tab type for more control:
struct ContentView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass", value: .search) {
SearchView()
}
Tab("Profile", systemImage: "person", value: .profile) {
ProfileView()
}
}
}
}
enum AppTab: Hashable {
case home, search, profile
}Tab Sections and Sidebar (iOS 18+)
On iPad, a TabView can present as a sidebar using .tabViewStyle(.sidebarAdaptable). Use TabSection to group tabs:
struct ContentView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) {
HomeView()
}
Tab("Search", systemImage: "magnifyingglass", value: .search) {
SearchView()
}
TabSection("Library") {
Tab("Favorites", systemImage: "heart", value: .favorites) {
FavoritesView()
}
Tab("Downloads", systemImage: "arrow.down.circle", value: .downloads) {
DownloadsView()
}
Tab("History", systemImage: "clock", value: .history) {
HistoryView()
}
}
Tab("Settings", systemImage: "gear", value: .settings) {
SettingsView()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}On iPhone, this renders as a standard tab bar (with overflow in "More" if needed). On iPad, it can show as a sidebar with sections.
Tab Customization (iOS 18+)
Allow users to reorder and hide tabs:
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) {
HomeView()
}
.customizationID("home") // Enable customization for this tab
Tab("Search", systemImage: "magnifyingglass", value: .search) {
SearchView()
}
.customizationID("search")
Tab("Profile", systemImage: "person", value: .profile) {
ProfileView()
}
.customizationID("profile")
.defaultVisibility(.hidden, for: .tabBar) // Hidden by default, user can add
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization) // Bind to persisted customization state
@AppStorage("tabCustomization") private var customization: TabViewCustomizationTabView with NavigationStack Per Tab
Each tab should have its own NavigationStack for independent navigation state:
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) {
NavigationStack {
HomeView()
.navigationTitle("Home")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
}
Tab("Search", systemImage: "magnifyingglass", value: .search) {
NavigationStack {
SearchView()
.navigationTitle("Search")
}
}
}Why per-tab stacks? Each tab maintains its own navigation history. Switching tabs and back preserves the drill-down state.
Badge
Tab("Inbox", systemImage: "tray", value: .inbox) {
InboxView()
}
.badge(unreadCount) // Shows count badge on tabPage Style TabView
For swipeable pages (onboarding, image galleries):
TabView {
OnboardingPage1()
OnboardingPage2()
OnboardingPage3()
}
.tabViewStyle(.page)
.indexViewStyle(.page(backgroundDisplayMode: .always))Common Mistakes
NavigationStack Wrapping TabView
// ❌ Navigation pushes replace the entire tab bar
NavigationStack {
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
}
}
// ✅ NavigationStack inside each tab
TabView {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
}Why? Wrapping TabView in a NavigationStack means any navigation push hides the tab bar entirely. Each tab should own its own stack.
Mixing .tabItem and Tab APIs
// ❌ Don't mix old and new APIs
TabView {
Tab("Home", systemImage: "house", value: .home) {
HomeView()
}
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
}
// ✅ Use one API consistently
TabView {
Tab("Home", systemImage: "house", value: .home) { HomeView() }
Tab("Search", systemImage: "magnifyingglass", value: .search) { SearchView() }
}Too Many Tabs
// ❌ More than 5 tabs on iPhone — cluttered, tiny targets
TabView {
Tab("Home", ...) { }
Tab("Search", ...) { }
Tab("Favorites", ...) { }
Tab("Messages", ...) { }
Tab("Profile", ...) { }
Tab("Settings", ...) { } // 6th tab — bad on iPhone
}
// ✅ Use TabSection with sidebarAdaptable for many sections
// On iPhone: show 4-5 tabs with "More" overflow
// On iPad: show sidebar with all sectionsApple HIG recommends 3-5 tabs for iPhone tab bars.
Programmatic Tab Selection Without Binding
// ❌ No way to programmatically switch tabs
TabView {
Tab("Home", systemImage: "house") { HomeView() }
}
// ✅ Use selection binding for programmatic control
@State private var selectedTab: AppTab = .home
TabView(selection: $selectedTab) {
Tab("Home", systemImage: "house", value: .home) { HomeView() }
}
// Now you can switch tabs programmatically:
selectedTab = .searchHiding the Tab Bar
On iOS 16+, hide the tab bar when pushing to a detail view:
NavigationStack {
ListView()
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
.toolbarVisibility(.hidden, for: .tabBar)
}
}Checklist
- [ ] Using
Tabtype (iOS 18+) or.tabItem(iOS 16+) consistently - [ ] Each tab has its own
NavigationStack(not wrappingTabView) - [ ] Selection binding for programmatic tab switching
- [ ] 3-5 tabs maximum for iPhone (use
TabSection+sidebarAdaptablefor more) - [ ] Badges on tabs where counts are relevant
- [ ]
.tabViewStyle(.sidebarAdaptable)considered for iPad with many sections - [ ] Tab bar hidden appropriately in immersive detail views