axiom-swiftui-nav-ref by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftui-nav-refSwiftUI 的导航 API 提供数据驱动、程序化的导航,可扩展从简单的堆栈到复杂的多列布局。在 iOS 16(2022 年)中引入了 NavigationStack 和 NavigationSplitView,在 iOS 18(2024 年)中演变为标签页/侧边栏统一,并在 iOS 26(2025 年)中通过液态玻璃设计进行了完善。
axiom-swiftui-nav 获取反模式、决策树、压力场景axiom-swiftui-nav-diag 进行导航问题的系统性故障排除广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
在以下情况下使用此技能:
| 年份 | iOS 版本 | 关键特性 |
|---|---|---|
| 2020 | iOS 14 | NavigationView(iOS 16 起已弃用) |
| 2022 | iOS 16 | NavigationStack、NavigationSplitView、NavigationPath、基于值的 NavigationLink |
| 2024 | iOS 18 | 标签页/侧边栏统一、sidebarAdaptable、TabSection、缩放过渡 |
| 2025 | iOS 26 | 液态玻璃导航、backgroundExtensionEffect、tabBarMinimizeBehavior |
NavigationView 自 iOS 16 起已弃用。在新代码中应专门使用 NavigationStack(单列推送/弹出)或 NavigationSplitView(多列)。关键改进:单一的 NavigationPath 取代了每个链接的 isActive 绑定、基于值的类型安全、内置的可编码状态恢复。请参阅“迁移到新的导航类型”文档。
NavigationStack 表示一个推送-弹出界面,类似于 iPhone 上的“设置”或 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)
}
}
路径绑定 + 基于值的 NavigationLink + navigationDestination(for:) 构成了核心的数据驱动导航模式。
// 正确:基于值(iOS 16+)
NavigationLink(recipe.name, value: recipe)
// 正确:使用自定义标签
NavigationLink(value: recipe) {
RecipeTile(recipe: recipe)
}
// 已弃用:基于视图(iOS 13-15)
NavigationLink(recipe.name) {
RecipeDetail(recipe: recipe) // 不要在新代码中使用
}
path 集合navigationDestination 修饰符映射路径值.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 放在惰性容器外部(不要放在 ForEach 内部)
为了代码组织,将其放在相关的 NavigationLinks 附近
必须位于 NavigationStack 层次结构内部
// 正确:在惰性容器外部 ScrollView { LazyVGrid(columns: columns) { ForEach(recipes) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) }
// 错误:在 ForEach 内部(可能不会被加载) ForEach(recipes) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } .navigationDestination(for: Recipe.self) { r in // 不要这样做 RecipeDetail(recipe: r) } }
NavigationPath 是一个类型擦除的集合,用于异构导航堆栈。
// 类型化数组:所有值类型相同
@State private var path: [Recipe] = []
// NavigationPath:混合类型
@State private var path = NavigationPath()
// 追加值
path.append(recipe)
// 弹出到上一个
path.removeLast()
// 弹出到根视图
path.removeLast(path.count)
// 或
path = NavigationPath()
// 检查数量
if path.count > 0 { ... }
// 深度链接:设置多个值
path.append(category)
path.append(recipe)
// 当所有值都可编码时,NavigationPath 是可编码的
@State private var path = NavigationPath()
// 编码
let data = try JSONEncoder().encode(path.codable)
// 解码
let codableRep = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data)
path = NavigationPath(codableRep)
NavigationSplitView 创建可适应设备尺寸的多列布局。
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")
}
}
}
}
在侧边栏和详情列之间添加一个 content: 闭包作为中间列:
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)
}
在详情列内部放置一个 NavigationStack(path:),以便在保留侧边栏选择的同时实现网格到详情的向下钻取:
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { ... }
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
} content: {
Content()
} detail: {
Detail()
}
// 以编程方式控制可见性
columnVisibility = .detailOnly // 隐藏侧边栏和内容列
columnVisibility = .all // 显示所有列
columnVisibility = .automatic // 由系统决定
NavigationSplitView 自动适配:
在 iPhone 上,选择更改会自动转换为推送/弹出操作。
NavigationSplitView {
List { ... }
} detail: {
DetailView()
}
// 在 iPad/macOS 上,侧边栏自动获得液态玻璃外观
// 将内容扩展到玻璃侧边栏后面
.backgroundExtensionEffect() // 镜像并模糊安全区域外的内容
使用 .onOpenURL 接收 URL,使用 URLComponents 解析,然后操作 NavigationPath:
.onOpenURL { url in
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
path.removeLast(path.count) // 首先弹出到根视图
// 解析主机/路径以确定目标,然后 path.append(value)
}
对于多步骤深度链接(myapp://category/desserts/recipe/apple-pie),遍历 URL 路径组件,并将每个解析出的值追加到路径中,以构建完整的导航堆栈。
有关全面的深度链接示例、错误诊断和测试工作流程,请参阅 axiom-swiftui-nav-diag(模式 3)。
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 {
// 在出现时恢复
if let data = data {
navModel.jsonData = data
}
// 在更改时保存
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 // 存储 ID,而非完整对象
}
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] } // 优雅地处理已删除的项目
}
}
存储 ID(而非完整的模型对象),并使用 compactMap 来优雅地处理已删除的项目。如 4.1 所示,添加 jsonData 计算属性和 objectWillChangeSequence 以集成 SceneStorage。
TabView {
Tab("Watch Now", systemImage: "play") {
WatchNowView()
}
Tab("Library", systemImage: "books.vertical") {
LibraryView()
}
Tab(role: .search) {
NavigationStack {
SearchView()
.navigationTitle("Search")
}
.searchable(text: $searchText)
}
}
搜索标签页要求:具有搜索角色的标签页内容必须包裹在 NavigationStack 中,并且 .searchable() 应用于该堆栈。没有 NavigationStack,搜索字段将不会出现。有关基础的 .searchable 模式(建议、范围、令牌、程序化控制),请参阅 axiom-swiftui-search-ref。
TabView {
Tab("Home", systemImage: "house") {
NavigationStack {
HomeView()
.navigationDestination(for: Item.self) { item in
ItemDetail(item: item)
}
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
每个标签页都有自己的 NavigationStack,以便在切换标签页时保留导航状态。
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") {
// 更多标签页...
}
Tab(role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
TabSection 创建侧边栏分组。.sidebarAdaptable 在 iPad 上启用侧边栏,在 iPhone 上启用标签栏。具有 .search 角色的搜索标签页获得特殊放置。
@AppStorage("MyTabViewCustomization")
private var customization: TabViewCustomization
TabView {
Tab("Watch Now", systemImage: "play", value: .watchNow) {
WatchNowView()
}
.customizationID("Tab.watchNow")
.customizationBehavior(.disabled, for: .sidebar, .tabBar) // 无法被隐藏
Tab("Optional Tab", systemImage: "star", value: .optional) {
OptionalView()
}
.customizationID("Tab.optional")
.defaultVisibility(.hidden, for: .tabBar) // 默认隐藏
}
.tabViewCustomization($customization)
在 Tab 上使用 .contextMenu(menuItems:) 为其侧边栏表示添加上下文菜单(例如,在 Mac 上右键单击,在 iPad 侧边栏上长按)。
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")
}
}
有条件地返回一个空闭包以停用上下文菜单:
.contextMenu {
if canPin {
Button("Pin", systemImage: "pin") { pin() }
}
}
Tab 上的 .contextMenu 仅适用于侧边栏表示(iPad/Mac)。iPhone 标签栏上下文菜单需要 UIKit 互操作(通过 Introspect 或 UITabBarController 子类向 UITabBar 添加 UILongPressGestureRecognizer)。有关变通模式,请参阅 axiom-swiftui-nav-diag。
注意事项:依赖于私有的 UITabBarButton 子视图——跨 iOS 版本脆弱,不是公共 API 保证。
使用 .hidden(_:) 根据应用状态显示/隐藏标签页,同时保留其导航状态。
Tab("Libraries", systemImage: "square.stack") { LibrariesView() }
.hidden(context == .browse) // 根据应用状态隐藏
将 .hidden(condition) 应用于每个标签页。以此方式隐藏的标签页会保留其导航状态(与条件 if 渲染不同,后者会销毁并重新创建它们)。
关键区别:.hidden(_:) 保留标签页状态,条件渲染则不会。
// ✅ 隐藏时状态保留
Tab("Settings", systemImage: "gear") {
SettingsView() // 导航堆栈保留
}
.hidden(!showSettings)
// ❌ 条件改变时状态丢失
if showSettings {
Tab("Settings", systemImage: "gear") {
SettingsView() // 导航堆栈重新创建
}
}
Tab("Beta Features", systemImage: "flask") { BetaView() }
.hidden(!UserDefaults.standard.bool(forKey: "enableBetaFeatures"))
相同的模式适用于认证状态、购买状态和调试版本——将 .hidden() 绑定到任何布尔条件。
将状态更改包裹在 withAnimation 中,以实现平滑的标签栏布局过渡:
Button("Switch to Browse") {
withAnimation {
context = .browse
selection = .tracks // 切换到第一个可见标签页
}
}
// 标签栏在标签页出现/消失时产生动画
// 自动使用系统运动曲线
// 滚动时标签栏最小化
TabView { ... }
.tabBarMinimizeBehavior(.onScrollDown)
// 底部附件视图(始终可见)
TabView { ... }
.tabViewBottomAccessory {
PlaybackControls()
}
// 动态可见性(推荐用于迷你播放器)
// ⚠️ 需要 iOS 26.1+(非 26.0)
TabView { ... }
.tabViewBottomAccessory(isEnabled: showMiniPlayer) {
MiniPlayerView()
.transition(.opacity)
}
// isEnabled: true = 显示附件
// isEnabled: false = 隐藏并移除预留空间
// 带有专用搜索字段的搜索标签页
Tab(role: .search) {
NavigationStack {
SearchView()
.navigationTitle("Search")
}
.searchable(text: $searchText)
}
// 选中时变形为搜索字段
// ⚠️ 必须使用 NavigationStack 包装才能使搜索字段出现
// 后备方案:如果没有标签页具有 .search 角色,则标签视图将搜索应用于所有标签页,当所选标签页更改时重置搜索状态
附件可以根据 activeTab 切换每个标签页的内容,尽管苹果的用法(音乐迷你播放器)将其保持为全局。读取 @Environment(\.tabViewBottomAccessoryPlacement) 来调整布局:当在标签栏上方时为 .bar(完整控件),当与折叠的标签栏内联时为其他值(紧凑)。
将 tabViewBottomAccessory 保留用于跨标签页内容(播放、状态)。对于标签页特定操作,优先选择标签页内容视图内的浮动玻璃按钮。
| 修饰符 | 目标 | iOS | 用途 |
|---|---|---|---|
Tab(_:systemImage:value:content:) | — | 18+ | 带有选择值的新标签页语法 |
Tab(role: .search) | — | 18+ | 具有变形行为的语义搜索标签页 |
TabSection(_:content:) | — | 18+ | 在侧边栏视图中分组标签页 |
.contextMenu(menuItems:) | Tab | 18+ | 为标签页的侧边栏表示添加上下文菜单 |
.customizationID(_:) | Tab | 18+ | 启用用户自定义 |
.customizationBehavior(_:for:) | Tab | 18+ | 控制隐藏/重新排序权限 |
.defaultVisibility(_:for:) | Tab | 18+ | 设置初始可见性状态 |
.hidden(_:) | Tab | 18+ | 程序化可见性,同时保留状态 |
.tabViewStyle(.sidebarAdaptable) | TabView | 18+ | iPad 上为侧边栏,iPhone 上为标签栏 |
.tabViewCustomization($binding) | TabView | 18+ | 持久化用户标签页排列 |
.tabBarMinimizeBehavior(_:) | TabView | 26+ | 滚动时自动隐藏 |
.tabViewBottomAccessory(isEnabled:content:) | TabView | 26.1+ | 标签栏下方的动态内容 |
使用 Xcode 26 构建时自动采用:
NavigationSplitView {
Sidebar()
} detail: {
HeroImage()
.backgroundExtensionEffect() // 内容扩展到侧边栏后面
}
基础搜索 API 有关 .searchable、isSearching、建议、范围、令牌和程序化控制,请参阅 axiom-swiftui-search-ref。本节仅涵盖 iOS 26 的底部对齐改进。
NavigationSplitView {
Sidebar()
} detail: {
DetailView()
}
.searchable(text: $query, prompt: "What are you looking for?")
// 在 iPhone 上自动底部对齐,在 iPad 上顶部尾部对齐
// 当内容滚动到工具栏下方时自动模糊效果
// 移除任何自定义的变暗背景 - 它们会干扰
// 对于密集的 UI,调整锐度
ScrollView { ... }
.scrollEdgeEffectStyle(.soft) // .sharp, .soft
在 iOS 26 中,表单可以直接从呈现它们的按钮变形出来。将呈现的工具栏项设置为导航缩放过渡的源,并将表单内容标记为目标:
@Namespace private var namespace
// 表单从呈现按钮变形出来
.toolbar {
ToolbarItem {
Button("Settings") { showSettings = true }
.matchedTransitionSource(id: "settings", in: namespace)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
.navigationTransition(.zoom(sourceID: "settings", in: namespace))
}
其他呈现方式也能从液态玻璃控件平滑流出——菜单、警告和弹出框。对话框会自动从呈现它们的按钮变形出来,无需额外代码。
审核提示:如果您曾使用 presentationBackground 为表单应用自定义背景,请考虑移除它,让新的液态玻璃表单材质发光。部分高度的表单现在默认使用玻璃背景嵌入。
当每个目标视图声明自己的 .toolbar {} 时,iOS 26 会在 NavigationStack 推送/弹出期间自动变形工具栏。具有匹配的 toolbar(id:) 和 ToolbarItem(id:) ID 的项在过渡期间保持稳定(无弹跳),而不匹配的项则动画进入/退出。
关键规则:将 .toolbar {} 附加到 NavigationStack 内部的各个视图,而不是 NavigationStack 本身。否则,就没有可以变形的内容。
有关完整的工具栏变形 API,包括 DefaultToolbarItem、toolbar(id:) 稳定项、ToolbarSpacer 模式和故障排除,请参阅 axiom-swiftui-26-ref 技能。
在以下情况下使用协调器:
在以下情况下使用内置导航:
// Route 枚举定义所有可能的目标
enum AppRoute: Hashable {
case home
case category(Category)
case recipe(Recipe)
case settings
}
// Router 类管理导航
@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()
}
}
}
// 在视图中的使用
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)
}
}
// 在子视图中
struct RecipeCard: View {
let recipe: Recipe
@Environment(Router.self) private var router
var body: some View {
Button(recipe.name) {
router.navigate(to: .recipe(recipe))
}
}
}
对于较大的应用,提取一个具有 associatedtype Route: Hashable 和 var path: NavigationPath 的 Coordinator 协议。每个功能区域都有自己的协调器一致性,包含特定领域的路由和便捷方法(例如,showRecipeOfTheDay(),该方法重置路径并导航)。
// Router 易于测试
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 // 用于编码
NavigationPath(codableRepresentation) // 用于解码
.navigationTitle("Title")
.navigationDestination(for: Type.self) { value in View }
.searchable(text: $query)
.tabViewStyle(.sidebarAdaptable)
.tabBarMinimizeBehavior(.onScrollDown)
.backgroundExtensionEffect()
WWDC:2022-10054、2024-10147、2025-256、2025-323(使用新设计构建 SwiftUI 应用)
文档:/swiftui/tabrole/search、/swiftui/view/tabbarminimizebehavior(_:)、/swiftui/view/tabviewbottomaccessory(isenabled:content:)
技能:axiom-swiftui-nav、axiom-swiftui-nav-diag、axiom-swiftui-26-ref、axiom-liquid-glass、axiom-swiftui-search-ref
最后更新 基于 WWDC 2022-10054、WWDC 2024-10147、WWDC 2025-256、WWDC 2025-323(使用新设计构建 SwiftUI 应用) 平台 iOS 16+、iPadOS 16+、macOS 13+、watchOS 9+、tvOS 16+
每周安装量
110
代码仓库
GitHub 星标数
590
首次出现
2026 年 1 月 21 日
安全审计
安装于
opencode96
codex90
gemini-cli87
cursor86
github-copilot84
claude-code84
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.
axiom-swiftui-nav for anti-patterns, decision trees, pressure scenariosaxiom-swiftui-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 |
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 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)
}
}
Path binding + value-presenting NavigationLink + navigationDestination(for:) form the core data-driven navigation pattern.
// 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)
}
}
Place navigationDestination outside lazy containers (not inside ForEach)
Place near related NavigationLinks for code organization
Must be inside NavigationStack hierarchy
// 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")
}
}
}
}
Add a content: closure between sidebar and detail for a middle column:
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)
}
Place a NavigationStack(path:) inside the detail column for grid-to-detail drill-down while preserving sidebar selection:
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { ... }
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
}
@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
Use .onOpenURL to receive URLs, parse with URLComponents, then manipulate NavigationPath:
.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).
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)
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.
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.
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.
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.
@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 .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).
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:
.contextMenu {
if canPin {
Button("Pin", systemImage: "pin") { pin() }
}
}
.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.
Use .hidden(_:) to show/hide tabs based on app state while preserving their navigation state.
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).
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
}
}
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.
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 (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
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.
| 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 |
Automatic adoption when building with Xcode 26:
NavigationSplitView {
Sidebar()
} detail: {
HeroImage()
.backgroundExtensionEffect() // Content extends behind sidebar
}
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.
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
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:
@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.
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.
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))
}
}
}
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).
// 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()
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+
Weekly Installs
110
Repository
GitHub Stars
590
First Seen
Jan 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode96
codex90
gemini-cli87
cursor86
github-copilot84
claude-code84
.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 |