Reference — Comprehensive SwiftUI navigation guide covering NavigationStack (iOS 16+), NavigationSplitView (iOS 16+), NavigationPath, deep linking, state restoration, Tab+Navigation integration (iOS 18+), Liquid Glass navigation (iOS 26+), and coordinator patterns
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.
SwiftUI's navigation APIs provide data-driven, programmatic navigation that scales from simple stacks to complex multi-column layouts. Introduced in iOS 16 (2022) with NavigationStack and NavigationSplitView, evolved in iOS 18 (2024) with Tab/Sidebar unification, and refined in iOS 26 (2025) with Liquid Glass design.
swiftui-nav for anti-patterns, decision trees, pressure scenariosswiftui-nav-diag for systematic troubleshooting of navigation issuesUse this skill when:
| Year | iOS Version | Key Features |
|---|---|---|
| 2020 | iOS 14 | NavigationView (deprecated iOS 16) |
| 2022 | iOS 16 | NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink |
| 2024 | iOS 18 | Tab/Sidebar unification, sidebarAdaptable, TabSection, zoom transitions |
| 2025 | iOS 26 | Liquid Glass navigation, backgroundExtensionEffect, tabBarMinimizeBehavior |
| Feature | NavigationView (iOS 13-15) | NavigationStack/SplitView (iOS 16+) |
|---|---|---|
| Programmatic navigation | Per-link isActive bindings | Single NavigationPath for entire stack |
| Deep linking | Complex, error-prone | Simple path manipulation |
| Type safety | View-based, runtime checks | Value-based, compile-time checks |
| State restoration | Manual, difficult | Built-in Codable support |
| Multi-column | NavigationStyle enum | Dedicated NavigationSplitView |
| Status | Deprecated iOS 16 | Current API |
NavigationStack represents a push-pop interface like Settings on iPhone or System Settings on macOS.
NavigationStack {
List(Category.allCases) { category in
NavigationLink(category.name, value: category)
}
.navigationTitle("Categories")
.navigationDestination(for: Category.self) { category in
CategoryDetail(category: category)
}
}
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
Key points:
path: $path binds the navigation state to a collectionNavigationLink appends values to the pathnavigationDestination(for:) maps values to views// Correct: Value-based (iOS 16+)
NavigationLink(recipe.name, value: recipe)
// Correct: With custom label
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
// Deprecated: View-based (iOS 13-15)
NavigationLink(recipe.name) {
RecipeDetail(recipe: recipe) // Don't use in new code
}
path collectionnavigationDestination modifiers over path values.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Category.self) { category in
CategoryList(category: category)
}
.navigationDestination(for: Chef.self) { chef in
ChefProfile(chef: chef)
}
}
navigationDestination outside lazy containers (not inside ForEach)// Correct: Outside lazy container
ScrollView {
LazyVGrid(columns: columns) {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
// Wrong: Inside ForEach (may not be loaded)
ForEach(recipes) { recipe in
NavigationLink(value: recipe) { RecipeTile(recipe: recipe) }
.navigationDestination(for: Recipe.self) { r in // Don't do this
RecipeDetail(recipe: r)
}
}
NavigationPath is a type-erased collection for heterogeneous navigation stacks.
// Typed array: All values same type
@State private var path: [Recipe] = []
// NavigationPath: Mixed types
@State private var path = NavigationPath()
// Append value
path.append(recipe)
// Pop to previous
path.removeLast()
// Pop to root
path.removeLast(path.count)
// or
path = NavigationPath()
// Check count
if path.count > 0 { ... }
// Deep link: Set multiple values
path.append(category)
path.append(recipe)
// NavigationPath is Codable when all values are Codable
@State private var path = NavigationPath()
// Encode
let data = try JSONEncoder().encode(path.codable)
// Decode
let codableRep = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data)
path = NavigationPath(codableRep)
NavigationSplitView creates multi-column layouts that adapt to device size.
struct MultipleColumns: View {
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
if let recipe = selectedRecipe {
RecipeDetail(recipe: recipe)
} else {
Text("Select a recipe")
}
}
}
}
NavigationSplitView {
// Sidebar
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} content: {
// Content column
List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(selectedCategory?.localizedName ?? "Recipes")
} detail: {
// Detail column
RecipeDetail(recipe: selectedRecipe)
}
Combine split view selection with stack-based drill-down:
struct MultipleColumnsWithStack: View {
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
.environmentObject(dataModel)
}
}
Key pattern: NavigationStack inside NavigationSplitView detail column enables grid-to-detail drill-down while preserving sidebar selection.
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
} content: {
Content()
} detail: {
Detail()
}
// Programmatically control visibility
columnVisibility = .detailOnly // Hide sidebar and content
columnVisibility = .all // Show all columns
columnVisibility = .automatic // System decides
NavigationSplitView automatically adapts:
Selection changes automatically translate to push/pop on iPhone.
NavigationSplitView {
List { ... }
} detail: {
DetailView()
}
// Sidebar automatically gets Liquid Glass appearance on iPad/macOS
// Extend content behind glass sidebar
.backgroundExtensionEffect() // Mirrors and blurs content outside safe area
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
.navigationDestination(for: Category.self) { category in
CategoryView(category: category)
}
}
.onOpenURL { url in
handleDeepLink(url)
}
}
func handleDeepLink(_ url: URL) {
// Parse URL: myapp://recipe/apple-pie
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
switch host {
case "recipe":
if let recipeName = components.path.dropFirst().description,
let recipe = dataModel.recipe(named: recipeName) {
path.removeLast(path.count) // Pop to root
path.append(recipe) // Push recipe
}
case "category":
if let categoryName = components.path.dropFirst().description,
let category = Category(rawValue: categoryName) {
path.removeLast(path.count)
path.append(category)
}
default:
break
}
}
}
// URL: myapp://category/desserts/recipe/apple-pie
func handleDeepLink(_ url: URL) {
let pathComponents = url.pathComponents.filter { $0 != "/" }
path.removeLast(path.count) // Reset to root
var index = 0
while index < pathComponents.count {
let component = pathComponents[index]
switch component {
case "category":
if index + 1 < pathComponents.count,
let category = Category(rawValue: pathComponents[index + 1]) {
path.append(category)
index += 2
}
case "recipe":
if index + 1 < pathComponents.count,
let recipe = dataModel.recipe(named: pathComponents[index + 1]) {
path.append(recipe)
index += 2
}
default:
index += 1
}
}
}
struct UseSceneStorage: View {
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $navModel.selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} detail: {
NavigationStack(path: $navModel.recipePath) {
RecipeGrid(category: navModel.selectedCategory)
}
}
.task {
// Restore on appear
if let data = data {
navModel.jsonData = data
}
// Save on changes
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
.environmentObject(dataModel)
}
}
class NavigationModel: ObservableObject, Codable {
@Published var selectedCategory: Category?
@Published var recipePath: [Recipe] = []
enum CodingKeys: String, CodingKey {
case selectedCategory
case recipePathIds // Store IDs, not full objects
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory)
try container.encode(recipePath.map(\.id), forKey: .recipePathIds)
}
init() {}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.selectedCategory = try container.decodeIfPresent(Category.self, forKey: .selectedCategory)
// Convert IDs back to objects, discarding deleted items
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] }
}
var jsonData: Data? {
get { try? JSONEncoder().encode(self) }
set {
guard let data = newValue,
let model = try? JSONDecoder().decode(NavigationModel.self, from: data)
else { return }
self.selectedCategory = model.selectedCategory
self.recipePath = model.recipePath
}
}
var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> {
objectWillChange
.buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest)
.values
}
}
Key pattern: Store IDs, not full model objects. Use compactMap to handle deleted items gracefully.
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
Tab(role: .search) {
SearchView()
}
}
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
Key pattern: Each tab has its own NavigationStack to preserve navigation state when switching tabs.
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
TabSection("Collections") {
Tab("Cinematic Shots", systemImage: "list.and.film") {
CinematicShotsView()
}
Tab("Forest Life", systemImage: "list.and.film") {
ForestLifeView()
}
}
TabSection("Animations") {
// More tabs...
}
Tab(role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
Key features:
TabSection creates groups visible in sidebar.sidebarAdaptable enables sidebar on iPad, tab bar on iPhone.search role gets special placement@AppStorage("MyTabViewCustomization")
private var customization: TabViewCustomization
TabView {
Tab("Watch Now", systemImage: "play", value: .watchNow) {
WatchNowView()
}
.customizationID("Tab.watchNow")
.customizationBehavior(.disabled, for: .sidebar, .tabBar) // Can't be hidden
Tab("Optional Tab", systemImage: "star", value: .optional) {
OptionalView()
}
.customizationID("Tab.optional")
.defaultVisibility(.hidden, for: .tabBar) // Hidden by default
}
.tabViewCustomization($customization)
Use .hidden(_:) to show/hide tabs based on app state while preserving their navigation state.
enum AppContext { case home, browse }
struct ContentView: View {
@State private var context: AppContext = .home
@State private var selection: TabID = .home
var body: some View {
TabView(selection: $selection) {
Tab("Home", systemImage: "house") {
HomeView()
}
.tag(TabID.home)
Tab("Libraries", systemImage: "square.stack") {
LibrariesView()
}
.tag(TabID.libraries)
.hidden(context == .browse) // Hide in browse context
Tab("Playlists", systemImage: "music.note.list") {
PlaylistsView()
}
.tag(TabID.playlists)
.hidden(context == .browse)
Tab("Tracks", systemImage: "music.note") {
TracksView()
}
.tag(TabID.tracks)
.hidden(context == .home) // Hide in home context
}
.tabViewStyle(.sidebarAdaptable)
}
}
Key difference: .hidden(_:) preserves tab state, conditional rendering does not.
// ✅ State preserved when hidden
Tab("Settings", systemImage: "gear") {
SettingsView() // Navigation stack preserved
}
.hidden(!showSettings)
// ❌ State lost when condition changes
if showSettings {
Tab("Settings", systemImage: "gear") {
SettingsView() // Navigation stack recreated
}
}
Feature Flags
Tab("Beta Features", systemImage: "flask") {
BetaView()
}
.hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures"))
Authentication State
Tab("Profile", systemImage: "person.circle") {
ProfileView()
}
.hidden(!authManager.isAuthenticated)
Purchase Status
Tab("Pro Features", systemImage: "star.circle.fill") {
ProFeaturesView()
}
.hidden(!purchaseManager.isPro)
Development Builds
Tab("Debug", systemImage: "hammer") {
DebugView()
}
.hidden(!isDevelopmentBuild)
private var isDevelopmentBuild: Bool {
#if DEBUG
return true
#else
return false
#endif
}
Wrap state changes in withAnimation for smooth tab bar layout transitions:
Button("Switch to Browse") {
withAnimation {
context = .browse
selection = .tracks // Switch to first visible tab
}
}
// Tab bar animates as tabs appear/disappear
// Uses system motion curves automatically
// Tab bar minimization on scroll
TabView { ... }
.tabBarMinimizeBehavior(.onScrollDown)
// Bottom accessory view
TabView { ... }
.tabViewBottomAccessory {
PlaybackControls()
}
// Search tab with dedicated search field
Tab(role: .search) {
SearchView()
}
// Morphs into search field when selected
| Modifier | Target | iOS | Purpose |
|---|---|---|---|
Tab(_:systemImage:value:content:) | — | 18+ | New tab syntax with selection value |
Tab(role: .search) | — | 18+ | Semantic search tab with morph behavior |
TabSection(_:content:) | — | 18+ | Group tabs in sidebar view |
.customizationID(_:) | Tab | 18+ | Enable user customization |
.customizationBehavior(_:for:) | Tab | 18+ | Control hide/reorder permissions |
.defaultVisibility(_:for:) | Tab | 18+ | Set initial visibility state |
.hidden(_:) | Tab | 18+ | Programmatic visibility with state preservation |
.tabViewStyle(.sidebarAdaptable) | TabView | 18+ | Sidebar on iPad, tabs on iPhone |
.tabViewCustomization($binding) | TabView | 18+ | Persist user tab arrangement |
.tabBarMinimizeBehavior(_:) | TabView | 26+ | Auto-hide on scroll |
.tabViewBottomAccessory { } | TabView | 26+ | Content below tab bar |
Automatic adoption when building with Xcode 26:
NavigationSplitView {
Sidebar()
} detail: {
HeroImage()
.backgroundExtensionEffect() // Content extends behind sidebar
}
NavigationSplitView {
Sidebar()
} detail: {
DetailView()
}
.searchable(text: $query, prompt: "What are you looking for?")
// Automatically bottom-aligned on iPhone, top-trailing on iPad
// Automatic blur effect when content scrolls under toolbar
// Remove any custom darkening backgrounds - they interfere
// For dense UIs, adjust sharpness
ScrollView { ... }
.scrollEdgeEffectStyle(.soft) // .sharp, .soft
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
ScrollView {
// Content
}
}
}
}
.tabBarMinimizeBehavior(.onScrollDown) // Minimizes on scroll
// Sheet morphs out of presenting button
.toolbar {
ToolbarItem {
Button("Settings") { showSettings = true }
.matchedTransitionSource(id: "settings", in: namespace)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.navigationTransition(.zoom(sourceID: "settings", in: namespace))
}
Use coordinators when:
Use built-in navigation when:
// Route enum defines all possible destinations
enum AppRoute: Hashable {
case home
case category(Category)
case recipe(Recipe)
case settings
}
// Router class manages navigation
@Observable
class Router {
var path = NavigationPath()
func navigate(to route: AppRoute) {
path.append(route)
}
func popToRoot() {
path.removeLast(path.count)
}
func pop() {
if !path.isEmpty {
path.removeLast()
}
}
}
// Usage in views
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 category):
CategoryView(category: category)
case .recipe(let recipe):
RecipeDetail(recipe: recipe)
case .settings:
SettingsView()
}
}
}
.environment(router)
}
}
// In child views
struct RecipeCard: View {
let recipe: Recipe
@Environment(Router.self) private var router
var body: some View {
Button(recipe.name) {
router.navigate(to: .recipe(recipe))
}
}
}
protocol Coordinator {
associatedtype Route: Hashable
var path: NavigationPath { get set }
func navigate(to route: Route)
}
@Observable
class RecipeCoordinator: Coordinator {
typealias Route = RecipeRoute
var path = NavigationPath()
enum RecipeRoute: Hashable {
case list(Category)
case detail(Recipe)
case edit(Recipe)
case relatedRecipes(Recipe)
}
func navigate(to route: RecipeRoute) {
path.append(route)
}
func showRecipeOfTheDay() {
path.removeLast(path.count)
if let recipe = DataModel.shared.recipeOfTheDay {
path.append(RecipeRoute.detail(recipe))
}
}
}
// Router is easily testable
func testNavigateToRecipe() {
let router = Router()
let recipe = Recipe(name: "Apple Pie")
router.navigate(to: .recipe(recipe))
XCTAssertEqual(router.path.count, 1)
}
func testPopToRoot() {
let router = Router()
router.navigate(to: .category(.desserts))
router.navigate(to: .recipe(Recipe(name: "Apple Pie")))
router.popToRoot()
XCTAssertTrue(router.path.isEmpty)
}
NavigationStack { content }
NavigationStack(path: $path) { content }
NavigationSplitView { sidebar } detail: { detail }
NavigationSplitView { sidebar } content: { content } detail: { detail }
NavigationSplitView(columnVisibility: $visibility) { ... }
NavigationLink(title, value: value)
NavigationLink(value: value) { label }
path.append(value)
path.removeLast()
path.removeLast(path.count)
path.count
path.codable // For encoding
NavigationPath(codableRepresentation) // For decoding
.navigationTitle("Title")
.navigationDestination(for: Type.self) { value in View }
.searchable(text: $query)
.tabViewStyle(.sidebarAdaptable)
.tabBarMinimizeBehavior(.onScrollDown)
.backgroundExtensionEffect()
Last Updated Based on WWDC 2022-10054, WWDC 2024-10147, WWDC 2025-256, WWDC 2025-323 Platforms iOS 16+, iPadOS 16+, macOS 13+, watchOS 9+, tvOS 16+