第44天 项目 9 第二部分
今天,我们将继续深入学习 SwiftUI 的导航功能,超越基础内容——不仅要了解如何处理编程式导航,还要学习如何加载和保存导航路径,以便恢复应用的状态。
最后这部分虽然在应用开发中占比不大,但却至关重要,遗憾的是,很多开发者都没有花时间把它做好。不妨想一想:你使用某个应用一段时间,导航到了某个特定页面,之后暂时退出应用去做其他事。如果之后重新启动应用时(即便“之后”可能是几天之后),一切都能恢复到之前的状态,岂不是很方便?
这只是今天主题中的一部分,希望大家能花时间把它做好。正如拉尔夫·沃尔多·爱默生曾经说过的:“志在高远,方能命中目标”——尽自己最大的努力总是没错的,因为即便未能完全达成目标,你也会处于一个很不错的位置!
好了,闲聊到此为止——我们开始吧!
今天你需要学习四个主题,从中其中你将了解编程式导航、NavigationPath、Codable 支持等内容。
- 使用 NavigationStack 实现编程式导航
- 使用 NavigationPath 导航到不同数据类型的页面
- 如何通过编程方式让 NavigationStack 返回根视图
- 如何使用 Codable 保存 NavigationStack 路径
使用 NavigationStack 实现编程式导航
作者:Paul Hudson 2023年11月2日
编程式导航允许我们仅通过代码就能从一个视图跳转到另一个视图,而无需等待用户执行特定操作。例如,应用可能正在处理用户输入,当处理完成后,你希望跳转到结果页面——你希望导航能在你指定的时机自动触发,而不是作为用户输入的直接响应。
在 SwiftUI 中,要实现这一点,需要将 NavigationStack 的路径绑定到一个存储导航数据的数组上。因此,我们可以从以下代码开始:
struct ContentView: View {
@State private var path = [Int]()
var body: some View {
NavigationStack(path: $path) {
VStack {
// 后续将添加更多代码
}
.navigationDestination(for: Int.self) { selection in
Text("你选择了 \(selection)")
}
}
}
}这段代码做了两件重要的事:
- 创建了一个
@State属性来存储整数数组。 - 将该属性绑定到
NavigationStack的path上,这意味着修改数组会自动导航到数组中数据对应的视图,同时当用户点击导航栏中的“返回”按钮时,数组也会相应地发生变化。
因此,我们可以用以下按钮替换代码中的“// 后续将添加更多代码”部分:
Button("显示 32") {
path = [32]
}
Button("显示 64") {
path.append(64)
}第一个按钮中,我们将整个数组设置为只包含数字 32。如果数组中原本有其他数据,这些数据都会被移除,这意味着 NavigationStack 会先回到初始状态,然后再导航到显示数字 32 的视图。
第二个按钮中,我们在数组末尾添加 64,这意味着 64 会被添加到当前导航路径的末尾。因此,如果数组中原本已经包含 32,那么此时导航栈中会有三个视图:原始视图(称为“根视图”)、显示数字 32 的视图,最后是显示数字 64 的视图。
你也可以一次性推入多个值,如下所示:
Button("先显示 32,再显示 64") {
path = [32, 64]
}这样会先显示一个用于展示 32 的视图,然后显示一个用于展示 64 的视图,因此用户需要点击两次“返回”按钮才能回到根视图。
你可以随意混合使用用户触发的导航和编程式导航——无论视图是通过哪种方式显示的,SwiftUI 都会确保 path 数组与显示的数据保持同步。
使用 NavigationPath 导航到不同数据类型的页面
作者:Paul Hudson 2023年11月2日
导航到不同数据类型的页面有两种实现方式。最简单的一种是,当你使用 navigationDestination() 处理不同数据类型,但不需要跟踪当前显示的具体路径时,这种情况很直接:只需多次添加 navigationDestination(),为每种想要导航到的数据类型各添加一次即可。
例如,我们可以显示五个数字和五个字符串,并以不同的方式导航到它们对应的视图:
NavigationStack {
List {
ForEach(0..<5) { i in
NavigationLink("选择数字:\(i)", value: i)
}
ForEach(0..<5) { i in
NavigationLink("选择字符串:\(i)", value: String(i))
}
}
.navigationDestination(for: Int.self) { selection in
Text("你选择了数字 \(selection)")
}
.navigationDestination(for: String.self) { selection in
Text("你选择了字符串 \(selection)")
}
}然而,当需要加入编程式导航时,情况会变得更复杂,因为你需要将某些数据绑定到导航栈的路径上。之前我向大家展示过如何使用整数数组来实现这一点,但现在我们可能需要处理整数或字符串,因此不能再使用简单的数组了。
SwiftUI 提供了一个特殊的类型 NavigationPath 来解决这个问题,它能够在单个路径中存储多种数据类型。实际上,它的使用方式与数组非常相似——我们可以创建一个使用该类型的属性,如下所示:
@State private var path = NavigationPath()将其绑定到 NavigationStack 上:
NavigationStack(path: $path) {然后通过编程方式向其中推入数据,例如通过工具栏按钮:
.toolbar {
Button("推入 556") {
path.append(556)
}
Button("推入 Hello") {
path.append("Hello")
}
}如果你想深入了解,NavigationPath 属于我们所说的“类型擦除器”——它可以存储任何类型的 Hashable 数据,而不会暴露每个数据项的确切类型。
如何通过编程方式让 NavigationStack 返回根视图
作者:Paul Hudson 2023年11月2日
在 NavigationStack 中深入多层视图后,常常需要返回到初始视图。例如,用户可能正在下单,依次经过购物车页面、收货信息页面、付款信息页面,最后是订单确认页面,但完成订单后,你希望回到最开始的地方——即 NavigationStack 的根视图。
为了演示这一点,我们可以创建一个简单的示例,通过每次生成新的随机数,不断推入新的视图。
首先,下面是我们的 DetailView,它将当前数字作为标题显示,并且有一个按钮,点击后会推入一个包含新随机数的视图:
struct DetailView: View {
var number: Int
var body: some View {
NavigationLink("前往随机数字页面", value: Int.random(in: 1...1000))
.navigationTitle("数字:\(number)")
}
}接下来,我们可以在 ContentView 中展示这个视图,初始值设为 0,并且每当有新的整数被选中时,就导航到新的 DetailView:
struct ContentView: View {
@State private var path = [Int]()
var body: some View {
NavigationStack(path: $path) {
DetailView(number: 0)
.navigationDestination(for: Int.self) { i in
DetailView(number: i)
}
}
}
}运行这段代码后,你会发现可以不断地推入新视图——你可以深入 20 层、50 层,甚至 500 层视图。
现在,如果你在 10 层视图深处,想要返回根视图,有两种方法:
- 如果你像上面的代码一样,使用简单数组来存储路径,可以调用数组的
removeAll()方法,清除路径中的所有数据,从而回到根视图。 - 如果你使用
NavigationPath来存储路径,可以将其设置为一个新的空NavigationPath实例,代码如下:path = NavigationPath()。
不过,这里有一个更大的问题:子视图无法访问原始的 path 属性,那该如何在子视图中实现返回根视图的功能呢?
针对这个问题,有两种解决方案:要么将路径存储在一个使用 @Observable 的外部类中,要么使用一个新的属性包装器 @Binding。之前我已经演示过 @Observable 的用法,所以这次我们重点介绍 @Binding。
你已经了解到,@State 允许我们在视图内部创建存储,以便在应用运行时修改值。而 @Binding 属性包装器则允许我们将 @State 属性传递到另一个视图中,并在该视图中修改它——我们可以在多个地方共享一个 @State 属性,在一个地方修改它,其他地方的值也会随之改变。
在当前代码中,这意味着需要给 DetailView 添加一个新属性,以获取导航路径数组的访问权限:
@Binding var path: [Int]现在,需要在 ContentView 中使用 DetailView 的两个地方都传入这个属性,代码如下:
DetailView(number: 0, path: $path)
.navigationDestination(for: Int.self) { i in
DetailView(number: i, path: $path)
}正如你所看到的,我们传入 $path,是因为我们想要传递绑定——我们希望 DetailView 能够读取和修改路径。
现在,我们可以给 DetailView 添加一个工具栏,用于操作路径数组:
.toolbar {
Button("返回首页") {
path.removeAll()
}
}如果你使用的是 NavigationPath,则可以使用以下代码:
.toolbar {
Button("返回首页") {
path = NavigationPath()
}
}像这样共享绑定是很常见的做法——TextField、Stepper 以及其他控件都是通过这种方式工作的。实际上,在项目 11 中创建自定义组件时,我们还会再次用到 @Binding,因为它非常实用。
如何使用 Codable 保存 NavigationStack 路径
作者:Paul Hudson 2024年7月3日
根据你使用的路径类型不同,可以通过两种方式使用 Codable 保存和加载导航栈路径:
- 如果你使用
NavigationPath存储NavigationStack的当前路径,SwiftUI 提供了两个辅助工具,可以更轻松地保存和加载路径。 - 如果你使用的是同类型数组(例如
[Int]或[String]),则不需要这些辅助工具,可以直接自由地加载或保存数据。
这两种方法非常相似,因此我会在这里一并介绍。
两种方法都依赖于将路径存储在视图外部,这样所有路径数据的加载和保存操作都会在后台进行,无需用户干预——一个外部类会自动处理这些工作。更准确地说,每当路径数据发生变化(无论是整数数组、字符串数组,还是 NavigationPath 对象),我们都需要保存新的路径,以便将来恢复,同时在类初始化时,也会从磁盘加载之前保存的数据。
当路径数据以整数数组的形式存储时,这个类的代码如下:
@Observable
class PathStore {
var path: [Int] {
didSet {
save()
}
}
private let savePath = URL.documentsDirectory.appending(path: "SavedPath")
init() {
if let data = try? Data(contentsOf: savePath) {
if let decoded = try? JSONDecoder().decode([Int].self, from: data) {
path = decoded
return
}
}
// 仍执行到这里?说明加载失败,从空路径开始
path = []
}
func save() {
do {
let data = try JSONEncoder().encode(path)
try data.write(to: savePath)
} catch {
print("导航数据保存失败")
}
}
}如果你使用的是 NavigationPath,只需做四处小修改。
首先,path 属性的类型需要改为 NavigationPath,而不是 [Int]:
var path: NavigationPath {
didSet {
save()
}
}其次,在初始化器中解码 JSON 数据时,需要解码为特定类型,然后使用解码后的数据创建一个新的 NavigationPath:
if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
path = NavigationPath(decoded)
return
}第三,如果解码失败,在初始化器的末尾,应该给 path 属性赋值一个新的空 NavigationPath 实例:
path = NavigationPath()最后,save() 方法需要写入导航路径的 Codable 表示形式。这一点与使用简单数组相比,会有一些不同,因为 NavigationPath 并不要求其存储的数据类型遵循 Codable 协议——它只要求数据类型遵循 Hashable 协议。因此,Swift 无法在编译时验证导航路径是否存在有效的 Codable 表示形式,所以我们需要主动获取并检查结果。
这意味着需要在 save() 方法的开头添加一个检查,尝试获取导航路径的 Codable 表示形式,如果获取失败,则立即退出方法:
guard let representation = path.codable else { return }这行代码要么返回可编码为 JSON 的数据,要么在路径中至少有一个对象无法编码时返回 nil。
最后,将 Codable 表示形式转换为 JSON,而不是转换原始的 Int 数组:
let data = try JSONEncoder().encode(representation)完整的类代码如下:
@Observable
class PathStore {
var path: NavigationPath {
didSet {
save()
}
}
private let savePath = URL.documentsDirectory.appending(path: "SavedPath")
init() {
if let data = try? Data(contentsOf: savePath) {
if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
path = NavigationPath(decoded)
return
}
}
// 仍执行到这里?说明加载失败,从空路径开始
path = NavigationPath()
}
func save() {
guard let representation = path.codable else { return }
do {
let data = try JSONEncoder().encode(representation)
try data.write(to: savePath)
} catch {
print("导航数据保存失败")
}
}
}现在,你可以像往常一样编写 SwiftUI 代码,只需确保将 NavigationStack 的路径绑定到 PathStore 实例的 path 属性上即可。例如,下面的代码可以显示带有随机整数的视图——你可以随意推入多个视图,然后退出并重新启动应用,应用会完全恢复到你离开时的状态:
struct DetailView: View {
var number: Int
var body: some View {
NavigationLink("前往随机数字页面", value: Int.random(in: 1...1000))
.navigationTitle("数字:\(number)")
}
}
struct ContentView: View {
@State private var pathStore = PathStore()
var body: some View {
NavigationStack(path: $pathStore.path) {
DetailView(number: 0)
.navigationDestination(for: Int.self) { i in
DetailView(number: i)
}
}
}
}