使用 SwiftUI 的 NavigationStack 组件进行页面导航
了解如何在 iOS 16 或以上版本中,使用 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("返回") // 自定义返回文字
}
}
}
)
}
并且,如果隐藏了导航栏,从屏幕左侧优化返回功能也会失效。
下面这两个帖子探讨了这个问题:
如何设置返回按钮语言(英文/中文)?
默认情况下,NavigationView 的返回按钮文字是由系统根据当前设备的 语言设置 和 应用中的导航层级标题 自动决定的。但有时候会出现,设备的系统语言设置为中文,但返回按钮显示为 “Back” 英文的情况。
解决方案是,确保项目支持中文本地化,通过以下步骤实现:
- 在 Xcode 中,选择项目文件 -> “Info” -> “Localizations”,并确保选择了 Chinese (Simplified) 或 Chinese (Traditional)。
- 为 Localizable.strings 文件添加中文翻译(可选)。
如何设置页面标题?
可以通过使用 .navigationTitle
修饰符来设置页面的标题。这个修饰符可以在视图中声明导航栏的标题文字。
struct GuideListView: View {
var body: some View {
NavigationView {
VStack {
// 你的页面内容
}
.navigationTitle("设置") // 设置页面标题
}
}
}
还可以使用.navigationBarTitleDisplayMode()
修饰符来调整标题的显示模式:
.large
:显示为大标题,适合用作页面主标题。.inline
:显示为小标题,与导航栏在一行中,适合用作嵌套页面的标题。
如何隐藏顶部安全空间?
相关资源
https://www.swiftanytime.com/blog/navigationstack-in-swiftui