Loading...
Loading...
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
npx skill4agent add fotescodev/ios-agent-skills axiom-swiftui-nav-refaxiom-swiftui-navaxiom-swiftui-nav-diag| 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 | 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 {
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)
}
}path: $pathNavigationLinknavigationDestination(for:)// 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
}pathnavigationDestination.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// 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)
}
}// 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)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)
}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)
}
}@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 decidesNavigationSplitView {
List { ... }
} detail: {
DetailView()
}
// Sidebar automatically gets Liquid Glass appearance on iPad/macOS
// Extend content behind glass sidebar
.backgroundExtensionEffect() // Mirrors and blurs content outside safe areastruct 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
}
}compactMapTabView {
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()
}
}
}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.sidebarAdaptable.search@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).hidden(_:)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)
}
}.hidden(_:)// ✅ 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
}
}Tab("Beta Features", systemImage: "flask") {
BetaView()
}
.hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures"))Tab("Profile", systemImage: "person.circle") {
ProfileView()
}
.hidden(!authManager.isAuthenticated)Tab("Pro Features", systemImage: "star.circle.fill") {
ProFeaturesView()
}
.hidden(!purchaseManager.isPro)Tab("Debug", systemImage: "hammer") {
DebugView()
}
.hidden(!isDevelopmentBuild)
private var isDevelopmentBuild: Bool {
#if DEBUG
return true
#else
return false
#endif
}withAnimationButton("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 (always visible)
TabView { ... }
.tabViewBottomAccessory {
PlaybackControls()
}
// Dynamic visibility (recommended for mini-players)
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) {
SearchView()
}
// Morphs into search field when selected| Modifier | Target | iOS | Purpose |
|---|---|---|---|
| — | 18+ | New tab syntax with selection value |
| — | 18+ | Semantic search tab with morph behavior |
| — | 18+ | Group tabs in sidebar view |
| Tab | 18+ | Enable user customization |
| Tab | 18+ | Control hide/reorder permissions |
| Tab | 18+ | Set initial visibility state |
| Tab | 18+ | Programmatic visibility with state preservation |
| TabView | 18+ | Sidebar on iPad, tabs on iPhone |
| TabView | 18+ | Persist user tab arrangement |
| TabView | 26+ | Auto-hide on scroll |
| TabView | 26+ | Dynamic content below tab bar |
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, .softTabView {
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))
}// 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()