6.1 理解导航栈

原文链接: https://developer.apple.com/documentation/swiftui/understanding-the-navigation-stack

6.1 理解导航栈

了解导航栈、导航链接,以及如何在应用结构中管理导航类型。

概述

NavigationStack 是应用导航结构的容器。用导航栈可以在根视图之上呈现一叠视图。

NavigationStack 通过其构造器的路径参数把状态暴露给应用。要创建一个可以控制、或可以跟踪栈上视图的带路径导航栈,请使用 NavigationPath,或者指向一个包含 Hashable 元素的 RandomAccessCollection 与 RangeReplaceableCollection 的 Binding。

NavigationPath 是一个类型擦除的集合,你可以在其中存放异构的数据列表。对于同构数据,请改用 Array。由于 NavigationPath 是类型擦除的,它可以表示与导航栈中某个视图相对应的不同类型的数据。

提示:避免把模型类型用作导航路径的元素。请确保导航路径的元素足够轻量,不要把导航路径当作数据模型的传输方式。

导航栈的另一个元素是导航目标,它封装了人们可以在应用中导航到的各个视图。

你可以在 NavigationStack 上用下列方式呈现目标:

注意:值目标和视图目标链接并不直接描述可见的栈,而是指添加到路径上的数据。

你可以用 NavigationLink(destination:label:) 把视图推入 NavigationStack。使用这个构造器时,你既要指定标签(显示在链接自身上),也要指定目标(有人轻点链接时显示的视图)。

请把 NavigationLink 包在视图层级中更靠上的导航结构中——例如某个祖先视图里。如果不满足这个条件,链接通常显示为不可用。

下面是一个在 NavigationStack 中包含两个链接的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct DestinationView: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                ColorDetail(color: .mint, text: "Mint")
            } label: {
                Text("Mint")
            }
            
            NavigationLink {
                ColorDetail(color: .red, text: "Red")
            } label: {
                Text("Red")
            }
        }
    }
}

struct ColorDetail: View {
    var color: Color
    var text: String

    var body: some View {
        VStack {
            Text(text)
            color
         }
    }
}

在这个例子中,轻点标题为 “Mint” 的标签会把 ColorDetail(color: .mint, text: "Mint") 视图推入导航栈。导航栈的内容在深度 0 处是根视图(NavigationLink 自身),在深度 1 处是 ColorDetail(color: .mint, text: "Mint")。

使用 init(destination:label:) 时,请注意:

  • SwiftUI 会跟踪导航状态和导航路径的内容;不过,应用没有任何状态钩子可以用来得知系统何时推入了视图。
  • 它的状态无法以编程方式恢复。

要跟踪导航链接何时触发,请使用管理导航状态与组合链接中描述的有状态导航技术,而不要使用 onAppear(perform:) 或 View/task(priority:_:)。

用 navigationDestination(isPresented:destination:) 修饰符提供布尔值绑定,即可进行编程式导航。例如,你可以以编程方式把 ColorDetail 视图推入栈:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct DestinationView: View { 
    @State private var showDetails = false
    var favoriteColor: Color
    
    NavigationStack {
        VStack {
            Circle()
                .fill(favoriteColor)
            Button("Show details") {
                showDetails = true
            }
        }
        .navigationDestination(isPresented: $showDetails) {
            ColorDetail(color: favoriteColor, text: color.description)
        }
    }
}

当你希望基于状态切换而不是人们的交互来导航时,或者当应用呈现一次性的、数据类型与导航栈同构路径不同的目标时,可以使用这种做法。

当你把数据加入导航路径时,SwiftUI 会把数据类型映射到视图,并在有人轻点链接时把它推入导航栈。要描述栈所显示的视图,请在 NavigationStack 内使用 navigationDestination(for:destination:) 视图修饰符。

下面的例子把 DestinationView 实现为一组导航链接:

1
2
3
4
5
6
7
8
9
NavigationStack {
    List {
        NavigationLink("Mint", value: Color.mint)
        NavigationLink("Red", value: Color.red)
    }
    .navigationDestination(for: Color.self) { color in
        ColorDetail(color: color, text: color.description)
    }
}

在上面的例子中,SwiftUI 用语值的类型——在这个例子里是 Color——来确定合适的导航目标。借助基于值的导航,你可以为单个栈定义多种可能的目标。当有人轻点 “Mint” 时,SwiftUI 会把带值 .mint 的 ColorDetail 视图推入栈。

基于值的导航在目标类型混合的场景中尤为出色。你可以在颜色之外,再扩展应用来处理食谱相关内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct ValueView: View {
    private var recipes: [Recipe] = [.applePie, .chocolateCake]
    
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                ForEach(recipes) { recipe in
                    NavigationLink(recipe.description, value: recipe)
                }
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

struct RecipeDetailView: View {
    var recipe: Recipe
    
    var body: some View {
        Text(recipe.description)
    }
}

enum Recipe: Identifiable, Hashable, Codable {
    case applePie
    case chocolateCake
    
    var id: Self { self }
    
    var description: String {
        switch self {
        case .applePie:
            return "Apple Pie"
        case .chocolateCake:
            return "Chocolate Cake"
        }
    }
}

在这个例子中,NavigationStack 支持两种目标类型:表示颜色的 Color 和表示食谱的 Recipe。SwiftUI 会根据导航链接中值的类型来确定正确的目标视图。

当你需要依据某个条目是否存在来导航到视图时,请使用 navigationDestination(item:destination:)。当条目绑定非 nil 时,SwiftUI 会把值传入目标闭包,并把视图推入栈。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct ContentView: View {
    private var recipes: [Recipe] = [.applePie, .chocolateCake]
    @State private var selectedRecipe: Recipe?
    
    var body: some View {
        NavigationStack {
            List(recipes, selection: $selectedRecipe) { recipe in
                NavigationLink(recipe.description, value: recipe)
            }
            .navigationDestination(item: $selectedRecipe) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

当有人轻点某个食谱时,selectedRecipe 的值会更新,SwiftUI 会把 RecipeDetailView(recipe: recipe) 推入导航栈。你可以把 selectedRecipe 设回 nil,从而把该视图从栈上弹出。

默认情况下,导航栈自行管理状态以跟踪栈上的视图。不过,你的应用可以通过用一个指向自己所创建的数据值集合的绑定来初始化栈,从而共同掌控这份状态。

当你想要观察该栈的导航状态时,请使用 init(path:root:),它接收一个指向 NavigationPath 实参的绑定。

NavigationPath 数据类型是一种异构集合类型,接受任何 Hashable 值。你可以调用 append(_:) 向路径中添加内容,也可以在人们轻点 init(value:label:) 这类值目标链接时添加。

当你用 init(_:value:) 把值推入栈时,实际上是把该值追加到路径上,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color)
            }
        }
    }
}

在这个例子中,当有人激活某个链接时,SwiftUI 会把对应的值(例如 Color.mint)加入 path。SwiftUI 用名为 path 的 State 属性来管理导航栈的状态。

init(path:root:) 还提供一个构造器,其路径参数接收指向 RandomAccessCollection 与 RangeReplaceableCollection 的 Binding。你可以把路径存为某个对象的属性,该对象利用 Observable() 宏数据类型;并使用 willSet、didSet 这类属性观察器或 onChange(of:initial:_:) 修饰符,在值目标链接触发时做出响应。

这种情况下,导航路径是同构集合类型,接受像 Array 这样的标准类型,或者下面这样的自定义数据类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Observable
class NavigationManager {
    var path: [Color] = [] {
        willSet {
            print("will set to \(newValue)")
        }
        
        didSet {
            print("didSet to \(path)")
        }
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
        }
    }
}

在上面的例子中,willSet 和 didSet 属性观察器会跟踪导航链接何时触发。

你也可以用对 path 变量的引用来执行编程式导航。例如,你可以把某个视图从栈上弹出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Observable
class NavigationManager {
    var path: [Color] = [] {
        willSet {
            print("will set to \(newValue)")
        }
        
        didSet {
            print("didSet to \(path)")
        }
    }
    
    @discardableResult
    func navigateBack() -> Color? {
        path.popLast()
    }
}

当你的栈只显示依赖单一数据类型的视图时,请使用标准类型;当你需要在单个栈中呈现多种数据类型时,请使用 NavigationPath,如下例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct ValueView: View {
    @State private var path = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                NavigationLink("Apple Pie", value: Recipe.applePie)
                NavigationLink("Chocolate Cake", value: Recipe.chocolateCake)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

注意:值目标链接和视图目标链接最终都会把用户可见的视图推入栈。不过,被推入的值目标会反映在栈的 path Binding 中(如果提供了的话),而被推入的视图目标不会。

组合使用时,这些导航 API 让你可以依据实际需要,同时使用这两种链接风格。

在下面的例子里,当有人轻点链接 “View Mint Color” 时,SwiftUI 会把值目标链接推入栈,随后再推入一个视图目标链接:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        RecipeDetailView(recipe: .applePie)
                    }
                }
        }
    }
}

这个例子中的代码运行、并逐一点击每个 NavigationLink 之后,导航栈会累积三个视图:

  • 根:NavigationStack 的起始视图。
  • 值的集合:推入路径的零个或多个值的序列,例如 Color.mint。这些值作为标识符或键,SwiftUI 用它们确定要呈现哪些视图。
  • 视图的集合:加入路径的视图序列,例如 RecipeDetailView。该视图被包在导航目标中,并在有人轻点链接时显示。

SwiftUI 会跟踪整条导航路径。其底层数据结构如下例所示:

1
Root → [Color.mint] → [RecipeDetailView]

从概念上讲,SwiftUI 会把基于视图的目标堆叠在基于值的目标之上。例如,下面的代码把上例中的 RecipeDetailView 换成了一个 NavigationLink:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        NavigationLink("Push another view", value: Color.pink)
                    }
                }
        }
    }
}

运行这个修改后的例子时,视图目标链接仍然位于栈顶。

无论你在栈上使用异构路径还是同构路径,都可以随时间观察导航路径的变化,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Observable
class NavigationManager {
    var path: [Color] = [] {
        didSet {
            print("didSet to \(path)")
        }
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        RecipeDetailView(recipe: .applePie)
                    }
                }
        }
    }
}

当有人浏览应用时,会打印出下列日志:

1
2
New path: []
New Path: [Color.mint]

之所以会打印这些日志,是因为视图目标导航链接不会引起应用能观察到的任何状态变化。如果栈上存在视图目标链接时你尝试推入一个值,SwiftUI 会弹出所有视图目标,并把该值对应的目标推入栈。

为导航路径恢复状态

为导航路径恢复状态,让你能在下一次启动时把界面恢复到上一次的交互位置,从而为使用应用的人提供连续性。

在 iOS 上,窗口或场景层级的状态恢复尤其重要,因为窗口会频繁地出现和消失。因此,像处理窗口或场景层级的状态恢复那样来考虑导航路径的状态恢复就很重要。要了解如何存储场景数据,参见用 SwiftUI 恢复应用状态。

使用 Codable,你可以依据路径数据类型是同构还是异构,用手动方式持久保存并加载导航栈路径。同构路径的存储方式如下例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@Observable
class NavigationManager {
    var path: [Recipe] = [] {
        didSet {
            save()
        }
    }
    
    /// 存储导航路径的 JSON 文件的 URL。
    private static var dataURL: URL {
        .documentsDirectory.appending(path: "NavigationPath.json")
    }
    
    init() {
        do {
            // 从 Documents 目录中的 'NavigationPath' 数据文件加载数据模型。
            let path = try load(url: NavigationManager.dataURL)
            self.path = path
        } catch {
            // 处理错误。
        }
    }
    
    func save() {
        let encoder = JSONEncoder()
        do {
            let data = try encoder.encode(path)
            try data.write(to: NavigationManager.dataURL)
        } catch {
            // 处理错误。
        }
    }
    
    /// 从先前保存的状态加载导航路径。
    func load(url: URL) throws -> [Recipe] {
        let data = try Data(contentsOf: url, options: .mappedIfSafe)
        let decoder = JSONDecoder()
        return try decoder.decode([Recipe].self, from: data)
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                NavigationLink("Apple Pie", value: Recipe.applePie)
                NavigationLink("Chocolate Cake", value: Recipe.chocolateCake)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

在上面的例子中,当 path 变化时,didSet 属性观察器会触发并调用 save 函数。该函数把新路径保存到磁盘,使应用在初始化 NavigationManager 时能够恢复它。

用 NavigationPath 存储异构路径,如下例所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Observable
class NavigationManager {
    var path = NavigationPath() {
        didSet {
            save()
        }
    }
    
    /// 存储导航路径的 JSON 文件的 URL。
    private static var dataURL: URL {
        .documentsDirectory.appending(path: "NavigationPath.json")
    }
    
    init() {
        do {
            // 从 Documents 目录中的 'NavigationPath' 数据文件加载数据模型。
            let path = try load(url: NavigationManager.dataURL)
            self.path = path
        } catch {
            // 处理错误
        }
    }
    
    func save() {
        guard let codableRepresentation = path.codable else { return }
        let encoder = JSONEncoder()
        do {
            let data = try encoder.encode(codableRepresentation)
            try data.write(to: NavigationManager.dataURL)
        } catch {
            // 处理错误。
        }
    }
    
    /// 从先前保存的数据加载导航路径。
    func load(url: URL) throws -> NavigationPath {
        let data = try Data(contentsOf: url, options: .mappedIfSafe)
        let decoder = JSONDecoder()
        let path = try decoder.decode(NavigationPath.CodableRepresentation.self, from: data)
        return NavigationPath(path)
    }
}

在上面的例子中,save 方法通过检查 path.codable 是否为空来判断。这个值以可序列化的形式描述路径的内容。如果路径中任何一个类型擦除的元素不遵循 Codable,它就返回 nil。

进行这项检查很重要,因为 NavigationPath 并不要求数据类型遵循 Codable。NavigationPath 只要求类型遵循 Hashable,因此你无法在编译期验证导航路径是 Codable 的有效表示。

要了解更多关于导航栈、导航链接和路径的内容,参见为 SwiftUI 应用引入健壮的导航结构。