编程式导航

编程式导航允许我们仅仅使用代码,即可实现从一个视图移动到另一个视图,而不是等待用户操作。例如,也许程序正忙于处理某些用户输入,并且我们希望在该工作完成后,再自动导航到结果屏幕。例如支付成功后,再跳转下一个页面。

在 SwiftUI 中,这是通过将 NavigationStackpath 路径参数绑定到【正在导航的任何数据的数组】来完成的。

<aside> 💡 可以根据需要混合用户导航和编程导航,SwiftUI 将确保 path 数组与您显示的任何数据保持同步,无论其显示方式如何。

</aside>

1. 使用 固定类型数组

// 例如,使用整数数组 [Int] 作为导航数据
struct ContentView: View {

		// 1. 创建一个 @State 属性来存储【整数数组】
    @State private var path = [Int]()

    var body: some View {

				// 2. 将该属性绑定到 NavigationStack 的 path
				// 这意味着【更改数组】将自动导航到数组中的任何内容,而且当用户按 Back 时也会更改数组导航栏
        NavigationStack(path: $path) {
            VStack {
            
                // 设置整个数组仅包含数字 32(如果数组发生其他变化使 32 被删除,则意味着导航将返回到其原始状态)
								Button("Show 32") { path = [32] }
								
								// 给数组附加值 64,这意味着它将添加到导航到的任何内容中。(如果数组已经包含 32,那么现在在堆栈中将拥有3个视图:原始视图(称为“根”视图),然后是显示数字 32 的视图,最后是显示数字 64 的视图)
								Button("Show 64") { path.append(64) }

								// 同时推送多个值。这将显示32的视图,然后显示64的视图,因此用户要点击“返回”两次才能返回根视图
								Button("Show 32 then 64") {
								    path = [32, 64]
								}
            }
				    .navigationDestination(for: Int.self) { selection in
                Text("You selected \\(selection)")
            }
        }
    }
}

2. 使用 NavigationPath

当需要导航到的值可能是整数,也有可能是字符串时,就不能再使用简单的数组作为 NavigationStack 的绑定参数了。解决方案是使用名为 NavigationPath 的特殊类型,它能够在单个路径中保存多种数据类型。NavigationPath 就是所说的类型擦除器 ,它存储任何类型的 Hashable 数据,而不暴露每个项目的数据类型。

简而言之,导航到不同类型的数组,就用 NavigationPath 进行区分,NavigationPath能够在单个路径对象中保存多种数据类型。

具体操作方式如下所示:

// 1. 新建一个名为 NavigationPath 特殊类型的状态参数
@State private var path = NavigationPath()

// 2. 绑定到导航视图
NavigationStack(path: $path) {...}

// 3. 以编程方式将内容推送到它,例如使用工具栏按钮:
.toolbar {
    Button("Push 556") {
        path.append(556)
    }

    Button("Push Hello") {
        path.append("Hello")
    }
}
// 例子2
struct ContentView: View {
    @State private var navPath = NavigationPath()
    var body: some View {
        NavigationStack(path: $navPath) {
            Button("Jump to random") {
                navPath.append(Int.random(in: 1..<50))
            }

            List(1..<50) { i in
                NavigationLink(value: "Row \\(i)") {
                    Label("Row \\(i)", systemImage: "\\(i).circle")
                }
            }
            .navigationDestination(for: Int.self) { i in
                Text("Int Detail \\(i)")
            }
            .navigationDestination(for: String.self) { i in
                Text("String Detail \\(i)")
            }
            .navigationTitle("Navigation")
        }
    }
}

具体应用

1. 直接返回根视图

当你在 NavigationStack 中,进行了N次下钻的访问后,想要一次性回到最初的视图,有两种做法:

2. 使用 “共享绑定” 操作路径数据

当希望从子视图中操作上面的数组、或者 path 数据时,会遇到提示“无权访问原始的 path 属性”的问题,这里有两个选择:

<aside> 💡 @State 可以在视图中创建一些存储,以便在程序运行时修改值。而 @Binding 属性包装器则允许将 @State 属性,传递到另一个视图并从那里修改它。这样我们可以在多个地方共享 @State 属性,并更改它在一个地方会改变它在任何地方。

</aside>

// 具体做法如下:

// 1. 需要在【主视图】使用 DetailView 的地方传递 path 参数(记得加 $ 美元符号)
DetailView(number: 0, path: $path)
    .navigationDestination(for: Int.self) { i in
        DetailView(number: i, path: $path)
}

// 2. 在【子视图】中添加新 @Binding 属性,接收传入的 path 数据,用以设置导航路径
@Binding var path: [Int]

// 3. 在【子视图】添加按钮来操作 path 路径数组
// 如果使用的是【简单数组】作为路径,则使用 removeAll 清空数组返回
.toolbar {
    Button("Home") { path.removeAll() }
}

// 如果使用的是【NavigationPath】作为路径,将其设置为新的空实例
.toolbar {
    Button("Home") { path = NavigationPath() }
}

//像这样共享绑定很常见,这正是 TextField 、 Stepper 和其他控件的工作方式。

3. 判断 NavigationPath 内容

当使用 NavigationPath 储存导航路径数据时,它可以通过 navigationDestination(for: Xxxxx.self) 去储存多种类型元素,如果想要判断其中是否包含特定类型的元素(例如 Project 类型),可以使用 NavigationPathelements 属性,elements 是一个数组,包含了当前导航路径中的所有元素。你可以通过遍历这个数组来检查是否包含 Project 类型的元素。

struct HomePageView: View {

    var body: some View {
        NavigationStack(path: $homePagePath) {
            // 在需要判断的地方
            if !containsProject() {
                // 这里可以放置不包含 Project 时的视图
                Text("当前没有 Project")
            }
        }
    }

    // 方法:判断 homePagePath 中是否包含 Project 类型的元素
    // 1. homePagePath.elements 访问 NavigationPath 的 elements 属性,获取当前导航路径中的所有元素
    // 2. 使用 contains(where:) 方法来检查数组中是否存在 Project 类型的元素。这里使用了类型检查 is 来判断元素的类型
    private func containsProject() -> Bool {
        return homePagePath.elements.contains(where: { $0 is Project })
    }

}

使用 Codable 保存和加载路径

A. 当路径为简单数组时

如果您使用同类数组(例如 [Int][String] )作为导航路径数据,那么可以直接使用传统的 userDefaults 的方法,自由地加载或保存数据。参见:‣ 。

// 以下展示的是将导航路径数据保存到【文件中】的例子,没有用 UserDefault
// 首先创建储存导航路径的类 PathStore, 由于数据是要共享的,所以加上 @Observable 关键词
@Observable
class PathStore {

    // 定义一个 path 参数, 类型是简单的整数数组
    var path: [Int] {
        //设置属性观察,当这个参数被设置时,调用 save 方法
        didSet {
            save()
        }
    }

    // 设置保存路径
    private let savePath = URL.documentsDirectory.appending(path: "SavedPath")

    // 类需要自己做初始化
    init() {
        // 初始化时:从用户数据中读取以往存下来的导航数据
        if let data = try? Data(contentsOf: savePath) {
            //初始化时:将存下来的导航数据解码,赋值给 path 参数
            if let decoded = try? JSONDecoder().decode([Int].self, from: data) {
                path = decoded
                return
            }
        }
        //如果读取数据或者解码不成功,就将导航数据设置为空(默认)
        path = []
    }

    //方法:每当path参数发生变化时,调用该方法将数据存入用户数据。
    func save() {
        do {
            let data = try JSONEncoder().encode(path)
            try data.write(to: savePath)
        } catch {
            print("Failed to save navigation data")
        }
    }
    
}
// 创建好类之后,接下来在视图代码中,将 NavigationStack 的路径绑定到 PathStore 实例的 path 属性
//【主视图】
struct ContentView: View {
    //创建类的实例:作为状态参数
    @State private var pathStore = PathStore()
    var body: some View {
        //绑定导航数据给NavigationStack
        NavigationStack(path: $pathStore.path) {
            DetailView(number: 0)
            .navigationDestination(for: Int.self) { i in
                DetailView(number: i)
            }
        }
    }
}

//【子视图】
// 这个子视图代码,可以让你不断导航任意数量的子视图,你可以随时退出并重新启动应用程序,来测试导航数据是否能恢复为您离开时的状态:
struct DetailView: View {
    var number: Int
    var body: some View {
        NavigationLink("Go to Random Number", value: Int.random(in: 1...1000))
            .navigationTitle("Number: \\(number)")
    }
}

B. 当路径为 NavigationPath

如果使用 NavigationPath 来存储 NavigationStack 的导航路径,则可以用以下方法来保存和加载路径:

// 在前者基础上需要进行 4 个小更改:

@Observable
class PathStore {

		// 1.Path 属性需要具有类型 NavigationPath,而不是 [Int]
    var path: NavigationPath {
        didSet {
            save()
        }
    }

    private let savePath = URL.documentsDirectory.appending(path: "SavedPath")

    init() {

				// 2.当在初始化器中解码 JSON 时,需要解码为特定类型,然后使用解码后的数据创建一个新的 NavigationPath
        if let data = try? Data(contentsOf: savePath) {
            if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
                path = NavigationPath(decoded)
                return
            }
        }

        // 3.如果解码失败,应该将一个新的空 NavigationPath 实例分配给初始化程序末尾的 path 属性
        path = NavigationPath()
    }

		// 4. save() 方法需要编写导航路径的 Codable 表示。这是与使用简单数组稍有不同的地方,因为 NavigationPath 不要求其数据类型符合 Codable,它只需要 Hashable 一致性。因此,Swift 无法在编译时验证导航路径是否存在有效的 Codable 表示
    func save() {

				// 这意味着在保存方法的开头,需要单独添加检查方法 .codable ,尝试检索 Codable 的导航路径,如果没有返回,则立即退出。这将返回准备编码为 JSON 的数据,或者如果路径中至少有一个对象无法编码,则返回 nil
        guard let representation = path.codable else { return }

        do {
						//最后,检查通过后,将该 Codable 的表示形式 representation 转换为 JSON,而不是原始的 Int 数组:
            let data = try JSONEncoder().encode(representation)
            try data.write(to: savePath)
        } catch {
            print("Failed to save navigation data")
        }
    }
}

<aside> 💡 总结:以上两种方式都依赖于将【路径数据】存储在视图之外的类中,因此,路径数据的所有加载和保存都是在不可见的情况下发生的(外部类会自动处理它)。每当路径数据发生变化时([Int]、[String]、或 NavigationPath 对象),我们都需要保存新路径,以便将来保留它。并且当类被初始化时,我们还需要加载该数据。

</aside>