使用 NavigationStack 组件进行页面导航

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

使用 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 视图会被程序化地推送到导航栈中,展示该视图。

自定义导航栏外观[高级]

了解如何使用 SwiftUI 提供的导航相关的修饰器,自定义你的导航栏外观,请继续阅读这篇文章:

自定义你的 NavigationStack 外观
了解如何使用 SwiftUI 提供的一系列导航相关的修饰器,来自定义你的导航栏外观,创建更独特的外观。

相关资源

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