Skip to content

第44天 项目 9 第二部分

今天,我们将继续深入学习 SwiftUI 的导航功能,超越基础内容——不仅要了解如何处理编程式导航,还要学习如何加载和保存导航路径,以便恢复应用的状态。

最后这部分虽然在应用开发中占比不大,但却至关重要,遗憾的是,很多开发者都没有花时间把它做好。不妨想一想:你使用某个应用一段时间,导航到了某个特定页面,之后暂时退出应用去做其他事。如果之后重新启动应用时(即便“之后”可能是几天之后),一切都能恢复到之前的状态,岂不是很方便?

这只是今天主题中的一部分,希望大家能花时间把它做好。正如拉尔夫·沃尔多·爱默生曾经说过的:“志在高远,方能命中目标”——尽自己最大的努力总是没错的,因为即便未能完全达成目标,你也会处于一个很不错的位置!

好了,闲聊到此为止——我们开始吧!

今天你需要学习四个主题,从中其中你将了解编程式导航、NavigationPathCodable 支持等内容。

  • 使用 NavigationStack 实现编程式导航
  • 使用 NavigationPath 导航到不同数据类型的页面
  • 如何通过编程方式让 NavigationStack 返回根视图
  • 如何使用 Codable 保存 NavigationStack 路径

使用 NavigationStack 实现编程式导航

作者:Paul Hudson 2023年11月2日

编程式导航允许我们仅通过代码就能从一个视图跳转到另一个视图,而无需等待用户执行特定操作。例如,应用可能正在处理用户输入,当处理完成后,你希望跳转到结果页面——你希望导航能在你指定的时机自动触发,而不是作为用户输入的直接响应。

在 SwiftUI 中,要实现这一点,需要将 NavigationStack 的路径绑定到一个存储导航数据的数组上。因此,我们可以从以下代码开始:

swift
struct ContentView: View {
    @State private var path = [Int]()

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                // 后续将添加更多代码
            }
            .navigationDestination(for: Int.self) { selection in
                Text("你选择了 \(selection)")
            }
        }
    }
}

这段代码做了两件重要的事:

  1. 创建了一个 @State 属性来存储整数数组。
  2. 将该属性绑定到 NavigationStackpath 上,这意味着修改数组会自动导航到数组中数据对应的视图,同时当用户点击导航栏中的“返回”按钮时,数组也会相应地发生变化。

因此,我们可以用以下按钮替换代码中的“// 后续将添加更多代码”部分:

swift
Button("显示 32") {
    path = [32]
}

Button("显示 64") {
    path.append(64)
}

第一个按钮中,我们将整个数组设置为只包含数字 32。如果数组中原本有其他数据,这些数据都会被移除,这意味着 NavigationStack 会先回到初始状态,然后再导航到显示数字 32 的视图。

第二个按钮中,我们在数组末尾添加 64,这意味着 64 会被添加到当前导航路径的末尾。因此,如果数组中原本已经包含 32,那么此时导航栈中会有三个视图:原始视图(称为“根视图”)、显示数字 32 的视图,最后是显示数字 64 的视图。

你也可以一次性推入多个值,如下所示:

swift
Button("先显示 32,再显示 64") {
    path = [32, 64]
}

这样会先显示一个用于展示 32 的视图,然后显示一个用于展示 64 的视图,因此用户需要点击两次“返回”按钮才能回到根视图。

你可以随意混合使用用户触发的导航和编程式导航——无论视图是通过哪种方式显示的,SwiftUI 都会确保 path 数组与显示的数据保持同步。

使用 NavigationPath 导航到不同数据类型的页面

作者:Paul Hudson 2023年11月2日

导航到不同数据类型的页面有两种实现方式。最简单的一种是,当你使用 navigationDestination() 处理不同数据类型,但不需要跟踪当前显示的具体路径时,这种情况很直接:只需多次添加 navigationDestination(),为每种想要导航到的数据类型各添加一次即可。

例如,我们可以显示五个数字和五个字符串,并以不同的方式导航到它们对应的视图:

swift
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 来解决这个问题,它能够在单个路径中存储多种数据类型。实际上,它的使用方式与数组非常相似——我们可以创建一个使用该类型的属性,如下所示:

swift
@State private var path = NavigationPath()

将其绑定到 NavigationStack 上:

swift
NavigationStack(path: $path) {

然后通过编程方式向其中推入数据,例如通过工具栏按钮:

swift
.toolbar {
    Button("推入 556") {
        path.append(556)
    }

    Button("推入 Hello") {
        path.append("Hello")
    }
}

如果你想深入了解,NavigationPath 属于我们所说的“类型擦除器”——它可以存储任何类型的 Hashable 数据,而不会暴露每个数据项的确切类型。

如何通过编程方式让 NavigationStack 返回根视图

作者:Paul Hudson 2023年11月2日

NavigationStack 中深入多层视图后,常常需要返回到初始视图。例如,用户可能正在下单,依次经过购物车页面、收货信息页面、付款信息页面,最后是订单确认页面,但完成订单后,你希望回到最开始的地方——即 NavigationStack 的根视图。

为了演示这一点,我们可以创建一个简单的示例,通过每次生成新的随机数,不断推入新的视图。

首先,下面是我们的 DetailView,它将当前数字作为标题显示,并且有一个按钮,点击后会推入一个包含新随机数的视图:

swift
struct DetailView: View {
    var number: Int

    var body: some View {
        NavigationLink("前往随机数字页面", value: Int.random(in: 1...1000))
            .navigationTitle("数字:\(number)")
    }
}

接下来,我们可以在 ContentView 中展示这个视图,初始值设为 0,并且每当有新的整数被选中时,就导航到新的 DetailView

swift
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 层视图深处,想要返回根视图,有两种方法:

  1. 如果你像上面的代码一样,使用简单数组来存储路径,可以调用数组的 removeAll() 方法,清除路径中的所有数据,从而回到根视图。
  2. 如果你使用 NavigationPath 来存储路径,可以将其设置为一个新的空 NavigationPath 实例,代码如下:path = NavigationPath()

不过,这里有一个更大的问题:子视图无法访问原始的 path 属性,那该如何在子视图中实现返回根视图的功能呢?

针对这个问题,有两种解决方案:要么将路径存储在一个使用 @Observable 的外部类中,要么使用一个新的属性包装器 @Binding。之前我已经演示过 @Observable 的用法,所以这次我们重点介绍 @Binding

你已经了解到,@State 允许我们在视图内部创建存储,以便在应用运行时修改值。而 @Binding 属性包装器则允许我们将 @State 属性传递到另一个视图中,并在该视图中修改它——我们可以在多个地方共享一个 @State 属性,在一个地方修改它,其他地方的值也会随之改变。

在当前代码中,这意味着需要给 DetailView 添加一个新属性,以获取导航路径数组的访问权限:

swift
@Binding var path: [Int]

现在,需要在 ContentView 中使用 DetailView 的两个地方都传入这个属性,代码如下:

swift
DetailView(number: 0, path: $path)
    .navigationDestination(for: Int.self) { i in
        DetailView(number: i, path: $path)
    }

正如你所看到的,我们传入 $path,是因为我们想要传递绑定——我们希望 DetailView 能够读取和修改路径。

现在,我们可以给 DetailView 添加一个工具栏,用于操作路径数组:

swift
.toolbar {
    Button("返回首页") {
        path.removeAll()
    }
}

如果你使用的是 NavigationPath,则可以使用以下代码:

swift
.toolbar {
    Button("返回首页") {
        path = NavigationPath()
    }
}

像这样共享绑定是很常见的做法——TextFieldStepper 以及其他控件都是通过这种方式工作的。实际上,在项目 11 中创建自定义组件时,我们还会再次用到 @Binding,因为它非常实用。

如何使用 Codable 保存 NavigationStack 路径

作者:Paul Hudson 2024年7月3日

根据你使用的路径类型不同,可以通过两种方式使用 Codable 保存和加载导航栈路径:

  1. 如果你使用 NavigationPath 存储 NavigationStack 的当前路径,SwiftUI 提供了两个辅助工具,可以更轻松地保存和加载路径。
  2. 如果你使用的是同类型数组(例如 [Int][String]),则不需要这些辅助工具,可以直接自由地加载或保存数据。

这两种方法非常相似,因此我会在这里一并介绍。

两种方法都依赖于将路径存储在视图外部,这样所有路径数据的加载和保存操作都会在后台进行,无需用户干预——一个外部类会自动处理这些工作。更准确地说,每当路径数据发生变化(无论是整数数组、字符串数组,还是 NavigationPath 对象),我们都需要保存新的路径,以便将来恢复,同时在类初始化时,也会从磁盘加载之前保存的数据。

当路径数据以整数数组的形式存储时,这个类的代码如下:

swift
@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]

swift
var path: NavigationPath {
    didSet {
        save()
    }
}

其次,在初始化器中解码 JSON 数据时,需要解码为特定类型,然后使用解码后的数据创建一个新的 NavigationPath

swift
if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
    path = NavigationPath(decoded)
    return
}

第三,如果解码失败,在初始化器的末尾,应该给 path 属性赋值一个新的空 NavigationPath 实例:

swift
path = NavigationPath()

最后,save() 方法需要写入导航路径的 Codable 表示形式。这一点与使用简单数组相比,会有一些不同,因为 NavigationPath 并不要求其存储的数据类型遵循 Codable 协议——它只要求数据类型遵循 Hashable 协议。因此,Swift 无法在编译时验证导航路径是否存在有效的 Codable 表示形式,所以我们需要主动获取并检查结果。

这意味着需要在 save() 方法的开头添加一个检查,尝试获取导航路径的 Codable 表示形式,如果获取失败,则立即退出方法:

swift
guard let representation = path.codable else { return }

这行代码要么返回可编码为 JSON 的数据,要么在路径中至少有一个对象无法编码时返回 nil

最后,将 Codable 表示形式转换为 JSON,而不是转换原始的 Int 数组:

swift
let data = try JSONEncoder().encode(representation)

完整的类代码如下:

swift
@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 属性上即可。例如,下面的代码可以显示带有随机整数的视图——你可以随意推入多个视图,然后退出并重新启动应用,应用会完全恢复到你离开时的状态:

swift
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)
                }
        }
    }
}

本站使用 VitePress 制作