使用 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 进行导航

使用 NavigationStack 的基本方式是将其作为容器包裹在最顶层视图中,并通过 NavigationLink 进行导航。NavigationStack 提供了多种使用导航的方式。

基于视图的简单导航(NavigationLink)

NavigationStack 仍然支持基于视图的导航,和 NavigationView 语法类似,但不再使用 destination 参数,而是通过闭包来构建视图。这适合一些简单的导航场景。

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

import SwiftUI

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

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

NavigationLink {
    FolderDetail(id: workFolder.id)
} label: {
    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()
        }
    }
}

基于数据的导航(.navigationDestination)

这种方式提供了更高的灵活性,使用 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:显示为小标题,与导航栏在一行中,适合用作嵌套页面的标题。

如何隐藏顶部安全空间?

相关资源

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