使用 SwiftUI 的 NavigationStack 组件进行页面导航

了解如何在 iOS 16 或以上版本中,使用 SwiftUI 最新的 NavigationStack 组件进行页面导航,并学习通过编程的方式控制导航。

使用 SwiftUI 的 NavigationStack 组件进行页面导航

NavigationView 是 SwiftUI 的一个组件,从 iOS13 开始被引入,用于实现视图的导航功能。从 iOS16 开始,苹果引入了新的导航组件——NavigationStack,它解决了 NavigationView 组件的一些问题(我们在下面会说明),在处理复杂导航的时候会更加轻松。

在 iOS16 或以上版本中使用 NavigationView,Xcode 会给予该警告:NavigationView will be deprecated in a future version of iOS: use NavigationStack or NavigationSplitView instead.”,除非你有明确理由必须兼容 iOS 15 或以前版本,否则你都应该使用 NavigationStack。

NavigationStack 与 NavigationView 核心区别

NavigationView 和 NavigationStack 的核心区别之一确实体现在它们的驱动方式上:NavigationView 是基于视图驱动的,而 NavigationStack 现在是基于数据驱动的,这是一个非常受欢迎的改变。

  • 视图驱动(例如 NavigationView)是基于界面来导航的。换句话说,点击某个按钮会直接跳转到另一个视图,点击返回也只能回到上一个指定页面,导航的逻辑是围绕“从哪到哪”来设计的,且不容易直接控制导航的过程。
  • 数据驱动(例如 NavigationStack)是通过数据来控制导航的。你不仅是在界面上点击按钮跳转视图,还能通过操作导航背后的数据来决定显示哪些视图。例如,你可以直接告诉系统导航到某个页面,不依赖点击具体视图。

比如处理深层次的导航,当用户进入详情页并导航了好几层时(比如进入商品详情,查看评论,查看图片等),你想要通过一个按钮直接回到主页面,视图驱动(NavigationView) 就会比较难处理,而数据驱动(NavigationStack)则能够轻松应对这种场景。

我们通过一个更加具体点的例子来说明这个区别。假设你有一个展示商品的应用,用户可以点击商品进入详情页面。

视图驱动(NavigationView) 的方式是:用户点击商品 -> 进入详情页。这很直观,但如果用户已经在详情页,你想要通过某个事件(比如收到推送通知)直接让用户回到主页面,这就会变得困难。因为视图驱动的导航并没有记录导航路径的具体数据,所以开发者无法轻松操控导航历史。

NavigationView {
    List(products) { product in
        NavigationLink(destination: ProductDetailView(product: product)) {
            Text(product.name)
        }
    }
}

并且,因为依赖NavigationLink 的点击事件来推送视图。假如你想通过代码在用户点击之前直接打开某个特定的商品详情(例如点击推送消息),操作起来也同样会比较麻烦。

数据驱动(NavigationStack) 的方式是:你可以使用一个路径数组来控制用户在哪个页面。例如,你可以随时通过更新路径数组中的数据,决定当前展示的页面。

@State private var path = [Product]()

NavigationStack(path: $path) {
    List(products) { product in
        Button(action: {
            path.append(product)
        }) {
            Text(product.name)
        }
    }
    .navigationDestination(for: Product.self) { product in
        ProductDetailView(product: product)
    }
}

这里,path 是一个数组,它决定了用户导航到哪里。你不仅可以通过点击按钮添加新的视图到导航堆栈,还可以通过其他逻辑(例如推送消息、按钮事件等)随时调整导航路径,比如清空路径让用户回到主页面。

基于视图的简单导航(单一视图目标)

NavigationStack 仍然支持基于视图的简单导航,和 NavigationView 语法类似,这适合一些简单的导航场景。

在这种场景中,只需使用 NavigationStack() 搭配 NavigationLink() 组件即可,无需使用 .navigationDestination() 修饰器。

对于仅由文本组成的链接,可以使用一个方便的初始化器,它接受一个字符串并创建一个 Text 视图:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {
            
                // 导航到详情页面
                NavigationLink("查看详情") 
                {
                  DetailsView()
                }
                
                // 导航到设置页面
                NavigationLink("设置") 
                {
                  SettingsView()
                }
            }
            .navigationTitle("主页")
            .padding()
        }
    }
}

它的官方文档如下:

init(_:destination:) | Apple Developer Documentation
Creates a navigation link that presents a destination view, with a text label that the link generates from a localized string key.

如果源视图不是 Text,而是 View 视图,则需要使用 NavigationLink, label 闭包来实现:

NavigationLink {
    FolderDetail(id: workFolder.id)
} label: {
    SourceView()
}

避免使用之前的 NavigationLink(destination) 语法,这会导致导航出现预期之外的错误:

// 该初始化器设计与 NavgationView 搭配使用,不适合 NavigationStack 。
NavigationLink(
    destination: DetailView()
) {
    SourceView()
}

比如下面这个实例代码:

import SwiftUI

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 20) {

                // 使用自定义视图作为 NavigationLink 的触发器
                NavigationLink {
                    DetailsView()
                } label: {
                    HStack {
                        Image(systemName: "info.circle")
                            .font(.title)
                        Text("查看详情")
                            .font(.title2)
                            .bold()
                    }
                    .padding()
                    .background(Color.blue.opacity(0.1))
                    .cornerRadius(10)
                }

                // 使用自定义视图导航到设置页面
                NavigationLink {
                    SettingsView()
                } label: {
                    HStack {
                        Image(systemName: "gearshape")
                            .font(.title)
                        Text("设置")
                            .font(.title2)
                            .bold()
                    }
                    .padding()
                    .background(Color.green.opacity(0.1))
                    .cornerRadius(10)
                }
            }
            .navigationTitle("主页")
            .padding()
        }
    }
}

它的官方文档如下:

init(destination:label:) | Apple Developer Documentation
Creates a navigation link that presents the destination view.

基于数据的导航(单一视图目标)

对于更加复杂的导航场景,推荐使用基于数据的导航——这也是 NavigationStack 的核心优势之一。

基于数据的导航,核心是使用 .navigationDestination 修饰器(他有多种初始化器方式,适用于不同的场景)。

.navigationDestination(isPresented:) 是 iOS16 中引入的新的修饰符。通过设置一个布尔状态变量,该修饰符可以在满足条件时触发导航,呈现目标视图。

  • 以编程方式将单个视图推送到堆栈上。如果是动态的目标视图,例如 List 或者 Stack,应该使用后面讲到的【多视图目标】。
  • 这种方式无需使用 NavigationLink 组件
  • 与传统的 .sheet() 用法非常相似,这也是我喜欢使用它的原因之一。

以下示例代码演示了如何在 Button 的点击事件中触发 .navigationDestination

import SwiftUI

struct ExampleView: View {
    @State private var showDetailView = false

    var body: some View {
        NavigationStack {
            VStack {
                Button("Show Detail View") {
                    showDetailView = true
                }
            }
            .navigationDestination(isPresented: $showDetailView) {
                    DetailView()
                }
            .navigationTitle("Main View")
        }
    }
}

官方文档如下:

navigationDestination(isPresented:destination:) | Apple Developer Documentation
Associates a destination view with a binding that can be used to push the view onto a .

基于数据的导航(多视图目标)

要创建基于动态数据的多视图目标的导航,必须使用.navigationDestination(for:destination:) 修饰器,并且需要和 NavigationLink(_:value:)NavigationLink(value:label:) 搭配使用。

也就是说,这种方式必须使用三个组件:

  • NavigationStack() —— 一般建议放在 Tabview 下的根视图(若有 Tabview 的话)。
  • .navigationDestination(for:destination:)
  • NavigationLink(_:value:)NavigationLink(value:label:)

另外,Apple 最佳实践推荐使用 enum 来定义和管理导航路径。然后再 NavigationLink(value:) 和 .navigationDestination()中均调用 enum。

这种方式提供了更高的灵活性,使用 navigationDestination(for:destination:) 修饰符将某个数据类型与导航链接关联。可以为同一种数据类型创建多个 NavigationLink 实例,不同的数据值会展示相应的视图。

下面是一个完整实例:

import SwiftUI

// 数据模型:用户
struct User: Identifiable, Hashable {
    let id: Int
    let name: String
    let age: Int
    let profileDescription: String
}

// 模拟的用户数据
let users = [
    User(
        id: 1, name: "Alice", age: 28,
        profileDescription: "A software developer from New York."),
    User(
        id: 2, name: "Bob", age: 32,
        profileDescription: "A designer from California."),
    User(
        id: 3, name: "Charlie", age: 24,
        profileDescription: "A student from Texas."),
]

// 主页面
struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 40) {
                // 用户列表
                List(users) { user in
                    NavigationLink(value: user) {
                        HStack {
                            VStack(alignment: .leading) {
                                Text(user.name)
                                    .font(.headline)
                                Text("\(user.age) 岁")
                                    .font(.subheadline)
                                    .foregroundColor(.secondary)
                            }
                            Spacer()
                        }
                        .padding(.vertical, 8)
                    }
                }
                .listStyle(.insetGrouped)
                
                // 导航到设置页面
                NavigationLink("设置", value: "Settings")
                    .font(.title2)
                    .padding(.top, 20)
            }
            .navigationTitle("用户列表")
            .navigationDestination(for: User.self) { user in
                UserDetailsView(user: user)
            }
            .navigationDestination(for: String.self) { destination in
                if destination == "Settings" {
                    SettingsView()
                }
            }
        }
    }
}

// 用户详情页面
struct UserDetailsView: View {
    let user: User
    
    var body: some View {
        VStack(spacing: 20) {
            Text("姓名: \(user.name)")
                .font(.largeTitle)
                .bold()
            Text("年龄: \(user.age)")
                .font(.title3)
                .foregroundColor(.secondary)
            Text(user.profileDescription)
                .font(.body)
                .padding()
            
            // 导航到个人资料页面
            NavigationLink("查看个人资料") {
                UserProfileView(user: user)
            }
            .padding(.top, 20)
            .buttonStyle(.borderedProminent)
        }
        .navigationTitle("用户详情")
        .navigationBarTitleDisplayMode(.inline)
        .padding()
    }
}

// 用户个人资料页面
struct UserProfileView: View {
    let user: User
    
    var body: some View {
        VStack(spacing: 20) {
            Text("\(user.name) 的个人资料")
                .font(.largeTitle)
                .bold()
            Text("年龄: \(user.age)")
                .font(.title3)
                .foregroundColor(.secondary)
            Text(user.profileDescription)
                .font(.body)
                .padding()
        }
        .navigationTitle("个人资料")
        .padding()
    }
}

// 设置页面
struct SettingsView: View {
    @State private var isNotificationsEnabled = true
    @State private var isDarkModeEnabled = false
    
    var body: some View {
        Form {
            Toggle("接收通知", isOn: $isNotificationsEnabled)
            Toggle("启用暗黑模式", isOn: $isDarkModeEnabled)
        }
        .navigationTitle("设置")
    }
}

// 预览
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

使用 NavigationPath 进行编程导航

可以通过使用 NavigationPath 来实现编程导航。NavigationPath 是一个集合类型,表示导航栈中的历史记录。通过绑定 NavigationStack 的 path 属性到一个 @State 类型的 NavigationPath 变量,可以跟踪导航状态并实现编程控制。

struct ContentView: View {
    let screens = NavigationDestinations.allCases
    @State private var navigationPath = NavigationPath()
    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 40) {
                ForEach(screens, id: \.self) { screen in
                    NavigationLink(value: screen) {
                        Text(screen.rawValue)
                    }
                }
            }
            .navigationTitle("Main View")
            .navigationDestination(for: NavigationDestinations.self) { screen in
                switch screen {
                case .Details:
                    DetailsScreen()
                case .Profiles:
                    ProfileScreen()
                case .Settings:
                    SettingsScreen()
                }
            }
        }
    }
}

navigationPath 变量用于跟踪所有的导航历史。当用户导航至某个视图时,NavigationStack 会使用这个绑定状态来管理栈内的导航状态。可以通过直接修改 navigationPath 来实现编程推送和弹出视图。

例如,可以通过以下方式预配置导航路径:

@State private var navigationPath = [NavigationDestinations.Details, NavigationDestinations.Settings, NavigationDestinations.Profiles]

使用 append(_:) 方法在导航栈上推送新的视图,例如:

Button("Add view") {
    navigationPath.append(NavigationDestinations.Details)
}

这样,当点击按钮时,Details 视图会被程序化地推送到导航栈中,展示该视图。

高级用法

如何在 .sheet 视图中显示导航栏?

使用 .sheet 打开的模态窗口,默认不会显示 NavigationView 的导航栏。可以通过在 .sheet 中使用 NavigationView 包裹子组件,从而实现加载导航栏:

struct ContentView: View {
    @State var showSheet = false

    var body: some View {
        Button("Click"){
            self.showSheet.toggle()
        }
        .sheet(isPresented: $showSheet) {
           NavigationView {    // 在这里添加。
            DetailView()
           }
        }
    }
}


struct DetailView: View {
    var body: some View {
         NavigationLink(destination: ContentView3()){
             Text("Click Here")
         }
         .navigationBarTitle("Bar Title", displayMode: .inline)
    }
}

如何隐藏顶部导航栏?

默认情况下,当我们向下滑动时,NavigationStack 会激活顶部导航栏显示,在导航栏上会默认显示「返回按钮」,如果我们通过 .navigationBarItems 添加了其他按钮,也会在这里显示:

在某些情况下,我们可能并不需要导航栏。比如设置了 .navigationTransition() 效果之后,可以直接侧滑或下滑来返回,因此并不需要这个返回按钮。

此时,可以使用.navigationBarHidden()修饰器来隐藏导航栏:

NavigationStack {
    MyView()
        .navigationBarHidden(true)
}

在某些情况下(也许是 BUG),需要同时设置.navigationBarTitle才能使.navigationBarHidden正常工作:

NavigationStack {
    MyView()
        .navigationBarTitle("")
        .navigationBarHidden(true)
}

如何定制/隐藏顶部导航栏返回按钮?

NavigationView 默认会提供一个带箭头的返回按钮,但这个按钮是可以定制的。可以使用 navigationBarBackButtonHidden(true) 隐藏默认的返回按钮,并使用 navigationBarItems 自定义它。

NavigationStack {
    MyView()
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(
            leading: HStack {
                Button(action: {
                    dismiss()  // 使用 Environment 的 dismiss 返回上一页
                }) {
                    HStack {
                        Image(systemName: "chevron.left")  // 自定义返回图标
                        Text("返回")  // 自定义返回文字
                    }
                }
            }
        )
}

记住,这两个修饰器应该添加到你的目标页面上。如果 MyView 是在一个子组件,而你希望在这个子组件页面上隐藏返回按钮,请将它添加到子页面上。

通过自定义导航栏按钮,你可以实现自己设计的返回按钮。

如何在隐藏顶部导航栏的情况下保留返回按钮?

结论是:

  • 隐藏导航栏,同时保留返回按钮,使用 SwiftUI 无法做到(截止 2024 年 10 月)
  • 可以使用 .navigationTransition() 效果,达到隐藏导航栏之后,使用侧滑或下滑返回功能来返回上一级。
  • 或者使用 Zstack 自定义一个返回 Button,但是会失去屏幕左侧侧滑返回的功能

因为所有 .navigationBarItems 都必须依托在导航栏上存在。如果隐藏了导航栏,则自定义添加的 .navigationBarItems 也会无法显示。

也就是说下面这个代码是无效的:

NavigationStack {
    MyView()
        .navigationBarHidden(true)
        .navigationBarItems(
            leading: HStack {
                Button(action: {
                    dismiss()  // 使用 Environment 的 dismiss 返回上一页
                }) {
                    HStack {
                        Image(systemName: "chevron.left")  // 自定义返回图标
                        Text("返回")  // 自定义返回文字
                    }
                }
            }
        )
}

并且,如果隐藏了导航栏,从屏幕左侧优化返回功能也会失效。

下面这两个帖子探讨了这个问题:

SwiftUI: Hide Nav Bar On One View But Keep Swiping Back Gesture
by u/thebrandontyler in iOSProgramming
Hide the Navigationbar + the space around, but not the Back Button (SwiftUI)
by u/Boothosh in iOSProgramming

如何设置返回按钮语言(英文/中文)?

默认情况下,NavigationView 的返回按钮文字是由系统根据当前设备的 语言设置应用中的导航层级标题 自动决定的。但有时候会出现,设备的系统语言设置为中文,但返回按钮显示为 “Back” 英文的情况。

解决方案是,确保项目支持中文本地化,通过以下步骤实现:

  1. 在 Xcode 中,选择项目文件 -> “Info” -> “Localizations”,并确保选择了 Chinese (Simplified) 或 Chinese (Traditional)。
  1. 为 Localizable.strings 文件添加中文翻译(可选)。

如何设置页面标题?

可以通过使用 .navigationTitle 修饰符来设置页面的标题。这个修饰符可以在视图中声明导航栏的标题文字。

struct GuideListView: View {
    var body: some View {
        NavigationView {
            VStack {
                // 你的页面内容
            }
            .navigationTitle("设置") // 设置页面标题
        }
    }
}

还可以使用.navigationBarTitleDisplayMode() 修饰符来调整标题的显示模式:

  • .large:显示为大标题,适合用作页面主标题。
  • .inline:显示为小标题,与导航栏在一行中,适合用作嵌套页面的标题。

如何隐藏顶部安全空间?

常见问题

避免在 Tabview 外部使用 NavigationStack

Apple 不建议将 NavigationStack 放在 TabView 的外部。因为这样做可能会导致导航状态在不同的标签页之间共享,进而引发导航冲突和意外行为。

NavigationStack 管理其内部视图的导航状态。如果将它包裹在 TabView 外部,那么所有标签页将共享同一个导航栈。当在一个标签页中导航到某个视图,然后切换到另一个标签页时,导航栈的状态仍然保留。这可能导致导航路径混乱,甚至在不相关的标签页中显示错误的视图。

NavigationStack 移动到每个标签页的内容视图中

避免将 .navigationDestination() 修饰器放在 List 或 LazyStack 中

根据 SwiftUI 的最佳实践,不应在懒加载容器(如 ListLazyVStack)内使用 navigationDestination 修饰符

Do not put a navigation destination modifier inside a "lazy” container, like `List` or `LazyVStack`. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination. There's a misplaced `navigationDestination(for:destination:)` modifier for type `SettingsNavigation`. It will be ignored in a future release.

这个警告指出,在懒加载容器中使用 navigationDestination 会导致导航系统无法正确识别目的地视图。

在 iOS17 上,点击导航导致应用冻结

在 iOS 17 上,点击导航按钮可能会导致应用冻结。

以下代码在 iOS 18 上运行正常,但在 iOS 17 上点击 Button 后会出现应用卡死的问题:

import SwiftUI

struct NavigationStackDemoView: View {
    @State private var showSettingsView = false

    var body: some View {
        NavigationStack {
            VStack {
                Button(action: {
                    showSettingsView = true
                }) {
                    Image(systemName: "gearshape")
                        .resizable()
                        .frame(width: 50, height: 50)
                        .foregroundColor(.blue)
                    Text("Go to Settings")
                        .font(.headline)
                        .foregroundColor(.blue)
                }
            }
            .navigationDestination(isPresented: $showSettingsView) {
                SettingsView()
            }
            .navigationTitle("Main View")
        }
    }
}

目前原因尚不明确,解决方法是在代码中将 .navigationDestination 修饰符尽量靠近触发按钮的位置。例如:

import SwiftUI

struct NavigationStackDemoView: View {
    @State private var showSettingsView = false

    var body: some View {
        NavigationStack {
            VStack {
                Button(action: {
                    showSettingsView = true
                }) {
                    Image(systemName: "gearshape")
                        .resizable()
                        .frame(width: 50, height: 50)
                        .foregroundColor(.blue)
                    Text("Go to Settings")
                        .font(.headline)
                        .foregroundColor(.blue)
                }
                .navigationDestination(isPresented: $showSettingsView) {
                    SettingsView()
                }
            }
            .navigationTitle("Main View")
        }
    }
}

相关资源

Explore navigation design for iOS - WWDC22 - Videos - Apple Developer
Familiar navigation patterns can help people easily explore the information within your app — and save them from unnecessary confusion…

https://www.swiftanytime.com/blog/navigationstack-in-swiftui