This snapshot establishes the camera-to-result recognition flow and related tests while checking in the project skill/docs assets required for the configured local tooling.
973 lines
29 KiB
Markdown
973 lines
29 KiB
Markdown
---
|
||
name: axiom-swiftui-nav-ref
|
||
description: 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
|
||
license: MIT
|
||
compatibility: iOS 16+ (NavigationStack), iOS 18+ (Tab/Sidebar), iOS 26+ (Liquid Glass)
|
||
metadata:
|
||
version: "1.0.0"
|
||
last-updated: "2025-12-05"
|
||
---
|
||
|
||
# SwiftUI Navigation API Reference
|
||
|
||
## Overview
|
||
|
||
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.
|
||
|
||
#### Evolution timeline
|
||
- **2022 (iOS 16)** NavigationStack, NavigationSplitView, NavigationPath, value-based NavigationLink
|
||
- **2024 (iOS 18)** Tab/Sidebar unification, sidebarAdaptable style, zoom navigation transition
|
||
- **2025 (iOS 26)** Liquid Glass navigation chrome, bottom-aligned search, floating tab bars, backgroundExtensionEffect
|
||
|
||
#### Key capabilities
|
||
- **Data-driven navigation** NavigationPath represents stack state, enabling programmatic push/pop and deep linking
|
||
- **Multi-column layouts** NavigationSplitView adapts automatically (3-column on iPad → single stack on iPhone)
|
||
- **State restoration** Codable NavigationPath + SceneStorage for persistence across app launches
|
||
- **Tab integration** Per-tab NavigationStack with state preservation on tab switch (iOS 18+)
|
||
- **Liquid Glass** Automatic glass navigation bars, sidebars, and toolbars (iOS 26+)
|
||
|
||
#### When to use vs UIKit
|
||
- **SwiftUI navigation** New apps, multiplatform, simpler navigation flows → Use NavigationStack/SplitView
|
||
- **UINavigationController** Complex coordinator patterns, legacy code, specific UIKit features → Consider UIKit
|
||
|
||
#### Related Skills
|
||
- Use `axiom-swiftui-nav` for anti-patterns, decision trees, pressure scenarios
|
||
- Use `axiom-swiftui-nav-diag` for systematic troubleshooting of navigation issues
|
||
|
||
---
|
||
|
||
## When to Use This Skill
|
||
|
||
Use this skill when:
|
||
- **Implementing navigation APIs** NavigationStack, NavigationSplitView, NavigationPath, Tab+Navigation
|
||
- **Deep linking or state restoration** URL routing, Codable NavigationPath, SceneStorage
|
||
- **Adopting iOS 26+ features** Liquid Glass navigation, bottom-aligned search, tab bar minimization
|
||
- **Choosing navigation architecture** Stack vs SplitView vs coordinator patterns
|
||
|
||
---
|
||
|
||
## API Evolution
|
||
|
||
### Timeline
|
||
|
||
| 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 |
|
||
|
||
### NavigationView (Deprecated)
|
||
|
||
NavigationView is deprecated as of iOS 16. Use NavigationStack (single-column push/pop) or NavigationSplitView (multi-column) exclusively in new code. Key improvements: single NavigationPath replaces per-link `isActive` bindings, value-based type safety, built-in Codable state restoration. See "Migrating to new navigation types" documentation.
|
||
|
||
---
|
||
|
||
## NavigationStack Complete Reference
|
||
|
||
NavigationStack represents a push-pop interface like Settings on iPhone or System Settings on macOS.
|
||
|
||
### 1.1 Creating NavigationStack
|
||
|
||
#### Basic NavigationStack
|
||
|
||
```swift
|
||
NavigationStack {
|
||
List(Category.allCases) { category in
|
||
NavigationLink(category.name, value: category)
|
||
}
|
||
.navigationTitle("Categories")
|
||
.navigationDestination(for: Category.self) { category in
|
||
CategoryDetail(category: category)
|
||
}
|
||
}
|
||
```
|
||
|
||
#### With Path Binding (WWDC 2022, 6:05)
|
||
|
||
```swift
|
||
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)
|
||
}
|
||
}
|
||
```
|
||
|
||
Path binding + value-presenting NavigationLink + `navigationDestination(for:)` form the core data-driven navigation pattern.
|
||
|
||
### 1.2 NavigationLink (Value-Based)
|
||
|
||
#### Value-presenting NavigationLink
|
||
|
||
```swift
|
||
// 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
|
||
}
|
||
```
|
||
|
||
#### How NavigationLink works with NavigationStack
|
||
|
||
1. NavigationStack maintains a `path` collection
|
||
2. Tapping a value-presenting link appends the value to the path
|
||
3. NavigationStack maps `navigationDestination` modifiers over path values
|
||
4. Views are pushed onto the stack based on destination mappings
|
||
|
||
### 1.3 navigationDestination Modifier
|
||
|
||
#### Single Type
|
||
|
||
```swift
|
||
.navigationDestination(for: Recipe.self) { recipe in
|
||
RecipeDetail(recipe: recipe)
|
||
}
|
||
```
|
||
|
||
#### Multiple Types
|
||
|
||
```swift
|
||
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)
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Navigation Anti-Patterns
|
||
|
||
- **Never mix `navigationDestination(for:)` and `NavigationLink(destination:)`** in the same NavigationStack hierarchy — causes undefined behavior
|
||
- **Register `navigationDestination(for:)` once per data type** — duplicates cause the wrong view to appear
|
||
|
||
#### Placement rules
|
||
- Place `navigationDestination` outside lazy containers (not inside ForEach)
|
||
- Place near related NavigationLinks for code organization
|
||
- Must be inside NavigationStack hierarchy
|
||
|
||
```swift
|
||
// 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)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 1.4 NavigationPath
|
||
|
||
NavigationPath is a type-erased collection for heterogeneous navigation stacks.
|
||
|
||
#### Typed Array vs NavigationPath
|
||
|
||
```swift
|
||
// Typed array: All values same type
|
||
@State private var path: [Recipe] = []
|
||
|
||
// NavigationPath: Mixed types
|
||
@State private var path = NavigationPath()
|
||
```
|
||
|
||
#### NavigationPath Operations
|
||
|
||
```swift
|
||
// 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)
|
||
```
|
||
|
||
#### Codable Support
|
||
|
||
```swift
|
||
// 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 Complete Reference
|
||
|
||
NavigationSplitView creates multi-column layouts that adapt to device size.
|
||
|
||
### 2.1 Two-Column Layout
|
||
|
||
#### Basic Two-Column (WWDC 2022, 10:40)
|
||
|
||
```swift
|
||
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")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.2 Three-Column Layout
|
||
|
||
Add a `content:` closure between sidebar and detail for a middle column:
|
||
|
||
```swift
|
||
NavigationSplitView {
|
||
List(Category.allCases, selection: $selectedCategory) { category in
|
||
NavigationLink(category.localizedName, value: category)
|
||
}
|
||
} content: {
|
||
List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in
|
||
NavigationLink(recipe.name, value: recipe)
|
||
}
|
||
} detail: {
|
||
RecipeDetail(recipe: selectedRecipe)
|
||
}
|
||
```
|
||
|
||
### 2.3 NavigationSplitView with NavigationStack
|
||
|
||
Place a `NavigationStack(path:)` inside the detail column for grid-to-detail drill-down while preserving sidebar selection:
|
||
|
||
```swift
|
||
NavigationSplitView {
|
||
List(Category.allCases, selection: $selectedCategory) { ... }
|
||
} detail: {
|
||
NavigationStack(path: $path) {
|
||
RecipeGrid(category: selectedCategory)
|
||
.navigationDestination(for: Recipe.self) { recipe in
|
||
RecipeDetail(recipe: recipe)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 2.4 Column Visibility
|
||
|
||
```swift
|
||
@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
|
||
```
|
||
|
||
### 2.5 Automatic Adaptation
|
||
|
||
NavigationSplitView automatically adapts:
|
||
- **iPad landscape** All columns visible (depending on configuration)
|
||
- **iPad portrait/Slide Over** Collapses to overlay or single column
|
||
- **iPhone** Single navigation stack
|
||
- **Apple Watch/TV** Single navigation stack
|
||
|
||
Selection changes automatically translate to push/pop on iPhone.
|
||
|
||
### 2.6 iOS 26+ Liquid Glass Sidebar (WWDC 2025, 323)
|
||
|
||
```swift
|
||
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
|
||
```
|
||
|
||
---
|
||
|
||
## Deep Linking and URL Routing
|
||
|
||
### 3.1 Deep Link Pattern
|
||
|
||
Use `.onOpenURL` to receive URLs, parse with `URLComponents`, then manipulate `NavigationPath`:
|
||
|
||
```swift
|
||
.onOpenURL { url in
|
||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||
let host = components.host else { return }
|
||
path.removeLast(path.count) // Pop to root first
|
||
// Parse host/path to determine destination, then path.append(value)
|
||
}
|
||
```
|
||
|
||
For multi-step deep links (`myapp://category/desserts/recipe/apple-pie`), iterate URL path components and append each resolved value to build the full navigation stack.
|
||
|
||
For comprehensive deep linking examples, error diagnosis, and testing workflows, see `axiom-swiftui-nav-diag` (Pattern 3).
|
||
|
||
---
|
||
|
||
## State Restoration
|
||
|
||
### 4.1 Complete State Restoration (WWDC 2022, 18:12)
|
||
|
||
```swift
|
||
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)
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 Codable NavigationModel
|
||
|
||
```swift
|
||
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)
|
||
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
|
||
self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } // Discard deleted items
|
||
}
|
||
}
|
||
```
|
||
|
||
Store IDs (not full model objects) and use `compactMap` to handle deleted items gracefully. Add `jsonData` computed property and `objectWillChangeSequence` for SceneStorage integration as shown in 4.1.
|
||
|
||
---
|
||
|
||
## Tab + Navigation Integration
|
||
|
||
### 5.1 Tab Syntax (iOS 18+) (WWDC 2024, 4:27)
|
||
|
||
```swift
|
||
TabView {
|
||
Tab("Watch Now", systemImage: "play") {
|
||
WatchNowView()
|
||
}
|
||
Tab("Library", systemImage: "books.vertical") {
|
||
LibraryView()
|
||
}
|
||
Tab(role: .search) {
|
||
NavigationStack {
|
||
SearchView()
|
||
.navigationTitle("Search")
|
||
}
|
||
.searchable(text: $searchText)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Search tab requirement**: Contents of a search-role tab must be wrapped in `NavigationStack` with `.searchable()` applied to the stack. Without `NavigationStack`, the search field will not appear. For foundational `.searchable` patterns (suggestions, scopes, tokens, programmatic control), see `axiom-swiftui-search-ref`.
|
||
|
||
### 5.2 TabView with NavigationStack Per Tab
|
||
|
||
```swift
|
||
TabView {
|
||
Tab("Home", systemImage: "house") {
|
||
NavigationStack {
|
||
HomeView()
|
||
.navigationDestination(for: Item.self) { item in
|
||
ItemDetail(item: item)
|
||
}
|
||
}
|
||
}
|
||
Tab("Settings", systemImage: "gear") {
|
||
NavigationStack {
|
||
SettingsView()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Each tab has its own NavigationStack to preserve navigation state when switching tabs.
|
||
|
||
### 5.3 Sidebar-Adaptable TabView (WWDC 2024, 6:41)
|
||
|
||
```swift
|
||
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)
|
||
```
|
||
|
||
`TabSection` creates sidebar groups. `.sidebarAdaptable` enables sidebar on iPad, tab bar on iPhone. Search tab with `.search` role gets special placement.
|
||
|
||
### 5.4 Tab Customization (WWDC 2024, 10:45)
|
||
|
||
```swift
|
||
@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)
|
||
```
|
||
|
||
### 5.5 Tab Context Menus
|
||
|
||
Use `.contextMenu(menuItems:)` on a `Tab` to add a context menu to its sidebar representation (e.g., right-click on Mac, long-press on iPad sidebar).
|
||
|
||
```swift
|
||
Tab("Currently Reading", systemImage: "book") {
|
||
CurrentBooksList()
|
||
}
|
||
.contextMenu {
|
||
Button {
|
||
pinnedTabs.insert(.reading)
|
||
} label: {
|
||
Label("Pin", systemImage: "pin")
|
||
}
|
||
Button {
|
||
showShareSheet = true
|
||
} label: {
|
||
Label("Share", systemImage: "square.and.arrow.up")
|
||
}
|
||
}
|
||
```
|
||
|
||
Return an empty closure to deactivate the context menu conditionally:
|
||
|
||
```swift
|
||
.contextMenu {
|
||
if canPin {
|
||
Button("Pin", systemImage: "pin") { pin() }
|
||
}
|
||
}
|
||
```
|
||
|
||
#### iPhone Tab Bar Long-Press
|
||
|
||
`.contextMenu` on `Tab` only applies to the sidebar representation (iPad/Mac). iPhone tab bar context menus require UIKit interop (adding `UILongPressGestureRecognizer` to `UITabBar` via Introspect or a `UITabBarController` subclass). See `axiom-swiftui-nav-diag` for workaround patterns.
|
||
|
||
**Caveat**: Relies on private `UITabBarButton` subviews — fragile across iOS versions, not a public API guarantee.
|
||
|
||
### 5.6 Programmatic Tab Visibility
|
||
|
||
Use `.hidden(_:)` to show/hide tabs based on app state while preserving their navigation state.
|
||
|
||
#### State-Driven Tab Visibility
|
||
|
||
```swift
|
||
Tab("Libraries", systemImage: "square.stack") { LibrariesView() }
|
||
.hidden(context == .browse) // Hide based on app state
|
||
```
|
||
|
||
Apply `.hidden(condition)` to each tab. Tabs hidden this way preserve their navigation state (unlike conditional `if` rendering which destroys and recreates them).
|
||
|
||
#### State Preservation
|
||
|
||
**Key difference**: `.hidden(_:)` preserves tab state, conditional rendering does not.
|
||
|
||
```swift
|
||
// ✅ 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
|
||
}
|
||
}
|
||
```
|
||
|
||
#### Common Patterns
|
||
|
||
```swift
|
||
Tab("Beta Features", systemImage: "flask") { BetaView() }
|
||
.hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures"))
|
||
```
|
||
|
||
Same pattern applies to authentication state, purchase status, and debug builds — bind `.hidden()` to any boolean condition.
|
||
|
||
#### Animated Transitions
|
||
|
||
Wrap state changes in `withAnimation` for smooth tab bar layout transitions:
|
||
|
||
```swift
|
||
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
|
||
```
|
||
|
||
### 5.7 iOS 26+ Tab Features (WWDC 2025, 256)
|
||
|
||
```swift
|
||
// Tab bar minimization on scroll
|
||
TabView { ... }
|
||
.tabBarMinimizeBehavior(.onScrollDown)
|
||
|
||
// Bottom accessory view (always visible)
|
||
TabView { ... }
|
||
.tabViewBottomAccessory {
|
||
PlaybackControls()
|
||
}
|
||
|
||
// Dynamic visibility (recommended for mini-players)
|
||
// ⚠️ Requires iOS 26.1+ (not 26.0)
|
||
TabView { ... }
|
||
.tabViewBottomAccessory(isEnabled: showMiniPlayer) {
|
||
MiniPlayerView()
|
||
.transition(.opacity)
|
||
}
|
||
// isEnabled: true = shows accessory
|
||
// isEnabled: false = hides AND removes reserved space
|
||
|
||
// Search tab with dedicated search field
|
||
Tab(role: .search) {
|
||
NavigationStack {
|
||
SearchView()
|
||
.navigationTitle("Search")
|
||
}
|
||
.searchable(text: $searchText)
|
||
}
|
||
// Morphs into search field when selected
|
||
// ⚠️ NavigationStack wrapper required for search field to appear
|
||
// Fallback: If no tab has .search role, the tab view applies search
|
||
// to ALL tabs, resetting search state when the selected tab changes
|
||
```
|
||
|
||
#### Dynamic Bottom Accessory
|
||
|
||
The accessory can switch on `activeTab` for per-tab content, though Apple's usage (Music mini-player) keeps it global. Read `@Environment(\.tabViewBottomAccessoryPlacement)` to adapt layout: `.bar` when above tab bar (full controls), other values when inline with collapsed tab bar (compact).
|
||
|
||
Reserve `tabViewBottomAccessory` for cross-tab content (playback, status). For tab-specific actions, prefer floating glass buttons within the tab's content view.
|
||
|
||
### 5.8 Tab API Quick Reference
|
||
|
||
| 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 |
|
||
| `.contextMenu(menuItems:)` | Tab | 18+ | Add context menu to tab's sidebar representation |
|
||
| `.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(isEnabled:content:)` | TabView | 26.1+ | Dynamic content below tab bar |
|
||
|
||
---
|
||
|
||
## iOS 26+ Navigation Features
|
||
|
||
### 6.1 Liquid Glass Navigation (WWDC 2025, 323)
|
||
|
||
Automatic adoption when building with Xcode 26:
|
||
- Navigation bars become Liquid Glass
|
||
- Sidebars float above content with glass effect
|
||
- Tab bars float with new compact appearance
|
||
- Toolbars get automatic grouping
|
||
|
||
### 6.2 Background Extension Effect
|
||
|
||
```swift
|
||
NavigationSplitView {
|
||
Sidebar()
|
||
} detail: {
|
||
HeroImage()
|
||
.backgroundExtensionEffect() // Content extends behind sidebar
|
||
}
|
||
```
|
||
|
||
### 6.3 Bottom-Aligned Search (WWDC 2025, 256)
|
||
|
||
**Foundational search APIs** For `.searchable`, `isSearching`, suggestions, scopes, tokens, and programmatic control, see `axiom-swiftui-search-ref`. This section covers iOS 26 bottom-aligned refinement only.
|
||
|
||
```swift
|
||
NavigationSplitView {
|
||
Sidebar()
|
||
} detail: {
|
||
DetailView()
|
||
}
|
||
.searchable(text: $query, prompt: "What are you looking for?")
|
||
// Automatically bottom-aligned on iPhone, top-trailing on iPad
|
||
```
|
||
|
||
### 6.4 Scroll Edge Effect
|
||
|
||
```swift
|
||
// 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
|
||
```
|
||
|
||
### 6.5 Sheet Presentations with Zoom Transition
|
||
|
||
In iOS 26, sheets can morph directly out of the buttons that present them. Make the presenting toolbar item a source for a navigation zoom transition, and mark the sheet content as the destination:
|
||
|
||
```swift
|
||
@Namespace private var namespace
|
||
|
||
// 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))
|
||
}
|
||
```
|
||
|
||
Other presentations also flow smoothly out of Liquid Glass controls — menus, alerts, and popovers. Dialogs automatically morph out of the buttons that present them without additional code.
|
||
|
||
**Audit tip**: If you've used `presentationBackground` to apply custom backgrounds to sheets, consider removing it and let the new Liquid Glass sheet material shine. Partial height sheets are now inset with glass background by default.
|
||
|
||
### 6.6 Toolbar Morphing Transitions
|
||
|
||
iOS 26 automatically morphs toolbars during NavigationStack push/pop when each destination view declares its own `.toolbar {}`. Items with matching `toolbar(id:)` and `ToolbarItem(id:)` IDs stay stable during the transition (no bounce), while unmatched items animate in/out.
|
||
|
||
**Key rule**: Attach `.toolbar {}` to individual views inside NavigationStack, not to NavigationStack itself. Otherwise there is nothing to morph between.
|
||
|
||
See `axiom-swiftui-26-ref` skill for complete toolbar morphing API including DefaultToolbarItem, `toolbar(id:)` stable items, ToolbarSpacer patterns, and troubleshooting.
|
||
|
||
---
|
||
|
||
## Router/Coordinator Patterns
|
||
|
||
### 7.1 When to Use Coordinators
|
||
|
||
**Use coordinators when:**
|
||
- Navigation logic is complex with conditional flows
|
||
- Testing navigation in isolation
|
||
- Sharing navigation logic across multiple screens
|
||
- UIKit interop with heavy navigation requirements
|
||
|
||
**Use built-in navigation when:**
|
||
- Simple linear or hierarchical navigation
|
||
- State restoration is primary concern
|
||
- Fewer than 5-10 navigation destinations
|
||
- No need for navigation unit testing
|
||
|
||
### 7.2 Simple Router Pattern
|
||
|
||
```swift
|
||
// 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))
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.3 Coordinator Pattern with Protocol
|
||
|
||
For larger apps, extract a `Coordinator` protocol with `associatedtype Route: Hashable` and `var path: NavigationPath`. Each feature area gets its own coordinator conformance with domain-specific routes and convenience methods (e.g., `showRecipeOfTheDay()` that resets path and navigates).
|
||
|
||
### 7.4 Testing Navigation
|
||
|
||
```swift
|
||
// 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)
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Testing Checklist
|
||
|
||
- [ ] Deep links navigate correctly from cold start AND while running
|
||
- [ ] Pop to root clears entire stack
|
||
- [ ] State restores on app relaunch (SceneStorage key unique per scene)
|
||
- [ ] Deleted items handled gracefully in restoration (compactMap)
|
||
- [ ] NavigationSplitView collapses correctly on iPhone (selection pushes)
|
||
- [ ] iOS 26+: Liquid Glass appearance, bottom-aligned search, tab bar minimization
|
||
|
||
---
|
||
|
||
## API Quick Reference
|
||
|
||
### NavigationStack
|
||
|
||
```swift
|
||
NavigationStack { content }
|
||
NavigationStack(path: $path) { content }
|
||
```
|
||
|
||
### NavigationSplitView
|
||
|
||
```swift
|
||
NavigationSplitView { sidebar } detail: { detail }
|
||
NavigationSplitView { sidebar } content: { content } detail: { detail }
|
||
NavigationSplitView(columnVisibility: $visibility) { ... }
|
||
```
|
||
|
||
### NavigationLink
|
||
|
||
```swift
|
||
NavigationLink(title, value: value)
|
||
NavigationLink(value: value) { label }
|
||
```
|
||
|
||
### NavigationPath
|
||
|
||
```swift
|
||
path.append(value)
|
||
path.removeLast()
|
||
path.removeLast(path.count)
|
||
path.count
|
||
path.codable // For encoding
|
||
NavigationPath(codableRepresentation) // For decoding
|
||
```
|
||
|
||
### Modifiers
|
||
|
||
```swift
|
||
.navigationTitle("Title")
|
||
.navigationDestination(for: Type.self) { value in View }
|
||
.searchable(text: $query)
|
||
.tabViewStyle(.sidebarAdaptable)
|
||
.tabBarMinimizeBehavior(.onScrollDown)
|
||
.backgroundExtensionEffect()
|
||
```
|
||
|
||
---
|
||
|
||
## Resources
|
||
|
||
**WWDC**: 2022-10054, 2024-10147, 2025-256, 2025-323 (Build a SwiftUI app with the new design)
|
||
|
||
**Docs**: /swiftui/tabrole/search, /swiftui/view/tabbarminimizebehavior(_:), /swiftui/view/tabviewbottomaccessory(isenabled:content:)
|
||
|
||
**Skills**: axiom-swiftui-nav, axiom-swiftui-nav-diag, axiom-swiftui-26-ref, axiom-liquid-glass, axiom-swiftui-search-ref
|
||
|
||
---
|
||
|
||
**Last Updated** Based on WWDC 2022-10054, WWDC 2024-10147, WWDC 2025-256, WWDC 2025-323 (Build a SwiftUI app with the new design)
|
||
**Platforms** iOS 16+, iPadOS 16+, macOS 13+, watchOS 9+, tvOS 16+
|