文章介绍
本篇文章主要讲述navigationDestination()修饰器,该修饰器如何调用以及如何保存NavigationPath,文中还会涉及保存NavigationPath的相关代码,以便更多的人能够了解NavigationDestination以及NavigationPath的使用。
也作为我最近学习NavigationDestination和NavigationPath知识的巩固文章。
NavigationDestination
在Swift UI中,navigationDestination()是用于处理导航路径的修饰器,它通常和NavigationStack或NavigationLink配合使用,允许你为导航指定目标视图。通过navigationDestination(),可以基于数据或条件动态地为某些视图提供导航。
简单的可以理解为navigationDestination()会根据导航路径的数据类型展示对应的视图,比如我们新闻应用中存在多种类型,图片、作者以及评论等等,比如我们点击评论,然后展示评论的相关内容。
struct NewsFeedView: View {
var body: some View {
List(articles) { article in
NavigationLink(value: article) {
Text(article.title)
}
}
.navigationDestination(for: Article.self) { article in
ArticleDetailView(article: article)
}
.navigationDestination(for: Comment.self) { comment in
CommentDetailView(comment: comment)
}
}
}
因此,学习NavigationDestination()可以帮我们在内容动态变化的应用场景中展示不同的视图内容。
基本用法
NavigationDestination()主要用于在Swift UI的导航栈(NavigationStack)中为导航路径中的元素绑定目标视图。
.navigationDestination(for: DataType.self) { item in
DestinationView(item: item)
}
- DataType.self表示要导航的路径中涉及的数据类型。这里跟JSONDecoder解码的数据类型一样,在解码的类型后面添加.self。
- Item:表示传递给目标视图的数据
- DestinationView:表示涉及传递数据的目标视图。
基本导航示例代码
import SwiftUI
struct ContentView: View {
let items = ["Apple", "Banana", "Cherry", "Date"]
var body: some View {
NavigationStack {
List(items, id: \.self) { item in
NavigationLink(item, value: item) // 通过传递值来导航
}
.navigationDestination(for: String.self) { item in
DetailView(fruit: item) // 目标视图
}
.navigationTitle("Fruits")
}
}
}
struct DetailView: View {
let fruit: String
var body: some View {
Text("Selected fruit: \(fruit)")
.font(.largeTitle)
}
}
在这段代码中,我们通过List遍历了items数组,List中显示的是四个NavigationLink。
NavigationLink(item,value:item)
这段代码的第一部分item,表示显示的字符串内容。第二部分的value的item,表示用户点击后传递给navigationDestination。
比如,我们点击列表中的第一个内容“Apple”,NavigationLink就会将点击的内容value传递给navigationDestination,navigationDestination(for:)会根据传递的value类型来判断是否执行对应的代码。
.navigationDestination(for: String.self) { item in
DetailView(fruit: item) // 目标视图
}
因为我们的navigationDestination为String类型的导航路径绑定目标视图,而传递过来的value是String类型,所以就会传参并展示对应的DetailView()目标视图。
也因此,当我们点击“Apple”时,显示的内容就是“Selected fruit: Apple”
多个目标视图导航示例代码
当我们需要基于不同类型的数据导航到不同的目标视图时,我们可以设置navigationDestination支持多个实例,为不同类型的数据设置不同的目标视图。
import SwiftUI
struct ContentView: View {
let fruits = ["Apple", "Banana", "Cherry", "Date"]
let numbers = [1, 2, 3, 4]
var body: some View {
NavigationStack {
List {
Section("Fruits") {
ForEach(fruits, id: \.self) { fruit in
NavigationLink(fruit, value: fruit)
}
}
Section("Numbers") {
ForEach(numbers, id: \.self) { number in
NavigationLink("\(number)", value: number)
}
}
}
.navigationDestination(for: String.self) { fruit in
FruitDetailView(fruit: fruit)
}
.navigationDestination(for: Int.self) { number in
NumberDetailView(number: number)
}
.navigationTitle("Items")
}
}
}
struct FruitDetailView: View {
let fruit: String
var body: some View {
Text("Fruit: \(fruit)")
.font(.largeTitle)
}
}
struct NumberDetailView: View {
let number: Int
var body: some View {
Text("Number: \(number)")
.font(.largeTitle)
}
}
在这段示例代码中,我们分别设置了一个字符串数组和一个Int类型数组,接着是设置了两个对应的ForEach列表,两个navigationDestination以及两个视图。
当我们点击Numbers列表的数字1时,我们点击的NavigationLink对应的value值就会传递给下面的两个navigationDestination。
第一个 navigationDestination为String类型的导航路径绑定目标视图,因为它不会执行对应代码。
然后传递的value值会被第二个navigationDestination所捕获并执行对应的代码。
延伸问题
假设我们的NavigationStack中存在两个相同类型的navigationDestination,那么它们是否都会执行?
.navigationDestination(for: Int.self) { number in
NumberDetailView2(number: number)
}
.navigationDestination(for: Int.self) { number in
NumberDetailView(number: number)
}
答案是否定的,经过测试发现,Xcode会提示你:
A navigationDestination for “Swift.Int” was declared earlier on the stack. Only the destination declared closest to the root view of the stack will be used.
// 堆栈上先前声明了“Swift.Int”的 navigationDestination。仅使用最靠近堆栈根视图的声明目标。
同时,刚开始测试时,Xcode预览还存在一些点击问题,比如只能点击数字2,数字1、3、4都是点不动的。
Xcode模拟器正常跳转NumberDetailView2视图。
关闭Xcode模拟器后,Xcode预览恢复正常,可以点击所有数字并跳转到对应的NumberDetailView2视图。
因此,答案就是只使用最近的navigationDestination处理对应的代码。
NavigationPath
NavigationPath是Swift UI提供的一个专门用于导航的结构体。NavigationPath可以存储多个不同类型的值(只要它们都符合Hashable协议),因此它能够管理复杂的导航路径,你可以在路径中存储Int、String、自定义类型等多种类型。
因为它允许多种类型共存,所以它非常适合需要在导航过程中处理多种类型数据的情况。例如可以存储、追加或删除各种类型的导航元素,在不同类型的数据结构之间切换时非常有用,适用于复杂的导航场景。
初识NavigationStack导航
学习NavigationPath之前,先简单了解一下NavigationStack如何通过[Int]类型的导航路径来管理和追踪导航历史。
import SwiftUI
struct DetailView: View {
var number: Int
var body: some View {
NavigationLink("Go to Random Number", value: Int.random(in: 1...1000))
.navigationTitle("Number: \(number)")
}
}
struct Navigation: View {
@State private var path = [Int]() // 跟踪导航路径
var body: some View {
NavigationStack(path: $path) {
DetailView(number: 0) // 传递 Binding 给 DetailView
.navigationDestination(for: Int.self) { i in
DetailView(number: i) // 传递 Binding 给 DetailView
}
}
}
}
在这段代码中,我们在Navigation视图中定义了一个[Int]类型的导航路径path。
在NavigationStack(path: $path)中,NavigationStack需要通过数据绑定来跟踪导航路径的变化,path代表导航路径,它存储用户导航过程中的每个视图的数据。每当用户导航到新的视图时,path会更新。
我们将定义的[Int]类型的导航路径path传给NavigationStack(path:),并添加$表示数据绑定,这样Swift UI就会自动管理path的更新,当用户在导航栈前进或后退时,path的状态也会进行变化。
当用户导航到一个新的视图时,path数组就会添加新的Int值,并通过navigationDestination(for: Int.self)显示对应的目标视图。
示例
- 假设path为初始空数组[],用户点击按钮后,Int.random(in:1…1000)随机为8,此时path更新为[8],视图跳转到DetailView(number:8)。
- 当用户再次点击按钮后,Int.random(in:1…1000)随机为98,此时path更新为[8,98],视图跳转到DetailView(number:98)。
因此,我们可以通过path来管理复杂的导航场景,例如多层嵌套导航。
如购物平台的主页-百亿补贴-电脑数码-索尼相机,此时展示的是索尼相机购买页面,当我们左滑退出时,就会展示上一层视图,这里就用到了我们的NavigationPath进行导航的管理。同时也用到了我们的navigationDestination(for:)来捕获对应的NavigationLink的value值。
未使用NavigationPath的情况
当没有NavigationPath,返回操作会复杂一些,我之前在做Swift UI相关测试应用时,会在主视图设置一个step,每个视图都传入一个step参数,当在视图中点击返回按钮时,都会给当前视图的返回按钮设置上一级的step。
比如当前视图为创建存钱罐信息2视图,当点击返回视图时,返回的是创建存钱罐信息1视图,那么就需要给创建存钱罐信息2视图的返回按钮,设置step为创建存钱罐信息1的值,这样才可以返回。
var body: some Scene {
WindowGroup {
Group {
switch appData.currentStep {
case 0:
EntryLoadingPage()
.environmentObject(appData)
case 1:
PrivacyPolicy()
.environmentObject(appData)
default:
MainView()
.environmentObject(appData)
.environment(\.managedObjectContext, appDelegate.persistentContainer.viewContext)
}
}
}
}
上面的示例代码就是在未使用NavigationPath时,需要传递一个appData环境变量,每个视图通过修改其环境变量中的currentStep来改变显示的视图。
此外使用step类似变量的方式还不能从左侧滑动回上一视图,只能通过step去判断上一个视图的内容,因此NavigationPath可以引导我们做好嵌套视图的展示。
基本用法
定义NavigationPath()类型的变量,使用NavigationStack进行绑定NavigationPath实例。每当跳转新的视图时,NavigationPath实例就会更新,同时NavigationPath可以存储多个不同类型的值。
@State private var path = NavigationPath()
NavigationStack(path: $path) {
DetailView(number: 0)
.navigationDestination(for: Int.self) { i in
DetailView(number: i)
}
}
这段代码中,我们定义了NavigationPath类型的path,并绑定到NavigationStack中。每当导航路径发生变化(例如通过点击某个NavigationLink),path路径就会被更新。
在NavigationStack中展示DetailView(number: 0)视图,NavigationStack中含有navigationDestination(for: Int.self),这表示在我们的会捕获视图中NavigationLink的value值,并根据value值判断是否符合navigationDestination的类型,最后执行相关代码。
struct DetailView: View {
var number: Int
var body: some View {
VStack {
NavigationLink("Go to Random Number", value: Int.random(in: 1...1000))
}
.navigationTitle("Number: \(number)")
}
}
这是DetailView视图代码,其中含有NavigationLink导航链接。
NavigationPath更新方式
NavigationStack和navigationDestination的变化依赖于NavigationPath的更新,NavigationPath通常是通过以下方式更新:
1、点击NavigationLink:
当NavigationLink有一个指定的value,并且用户点击该链接时,NavigationStack内部的NavigationPath会更新,将value追加到该路径中,从而触发NavigationDestination显示对应的视图。
2、直接修改NavigationPath:
可以在代码中手动向NavigationPath中添加或删除值,例如path.append(someValue)可以直接改变导航路径,这也会触发NavigationStack渲染并显示新的目标视图。
根据基本用法的示例代码来看,我们在点击含有value值的NavigationLink时:
首先NavigationStack会捕获路径的变化,检测传递进来的path(通常是Binding<NavigationPath>),并更新其内部的导航信息。
路径一旦更新,navigationDestination会捕获新的路径,并根据路径中的值来判断与其匹配的目标视图。
保存NavigationPath
在学习NavigationPath后,我们需要进一步深入学习NavigationPath的保存方法,让我们在退出应用重新打开时,视图能够恢复到正常状态。
示例代码
struct 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
}
}
// Still here? Start with an empty path.
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("Failed to save navigation data")
}
}
}
代码分析
这段保存代码主要有三个部分:
var path: NavigationPath {
didSet {
save()
}
}
第一部分
第一部分是定义了一个NavigationPath类型的path变量,并设置了一个属性观察器didSet,当path被更改时,会触发didSet中的代码,并调用save()函数保存路径数据。
private let savePath = URL.documentsDirectory.appending(path: "SavedPath")
接着定义了一个savePath文件路径。这里涉及URL.documentsDirectory(指向应用的“Documents”文件夹),并将文件名设置为“SavePath”。
URL.documentsDirectory是获取应用的文档目录,通常用于存储用户生成的数据。比如文本文件、图片、音频等,属于iOS中常用的数据保存方式之一。
appending(_:)用于将一个完整的子路径添加到现有的URL,也可以添加相对路径,并且它支持多层路径结构。
let url = URL.documentsDirectory.appending(path: "folder/subfolder/SavedPath")
简单来说,就是我们通过URL.documentsDirecotry.appending(path:”SavePath”)创建了一个在Documents文件夹下的SavePath文件路径,就好像pwd变量存储的是Mac系统的工作目录的绝对路径名称。
% pwd
/Users/fangjunyu
扩展知识1
除了URL.documentsDirectory.appending(path:),还有一个类似的方法URL.documentsDirecotry.appendingPathComponent(_:)。
两者的区别就是处理单一文件名或目录名时,使用URL.documentsDirectory.appendingPathComponent(_:),处理多个路径组建时使用URL.documentsDiretory.appending(path:)。
1)appendingPathComponent(_:):
let url = URL.documentsDirectory.appendingPathComponent("example.txt")
这表示将example.txt这单一文件路径添加到Documents目录下。
2)appending(path:):
let url = URL.documentsDirectory.appending(path: "folder/subfolder/SavedPath")
当涉及多层路径嵌套时,则使用appending(path:)方法 ,这表示在Documents目录下创建folder/subfolder/SavedPath路径。
注意,我们的两种appending(path:)和appendingPathComponent(_:)都只是创建的路径,不会创建实际的目录文件。
在实际当中,我们需要data.write(to:)方法来创建文件,但不会创建中间的目录。如果中间的某个目录不存在,例如folder/subfolder/SavePath中的subfolder文件夹不存在,写入操作就会失败。
因此,当我们想要保存多层嵌套的路径时,我们可以通过FileManager来创建中间目录:
let fileManager = FileManager.default // 获取当前的 FileManager 实例,这是一个用于管理文件系统的类。
let url = URL.documentsDirectory.appending(path: "folder/subfolder/SavedPath")
let directory = url.deletingLastPathComponent() // 移除最后的文件名部分,得到该文件所在的目录路径。
if !fileManager.fileExists(atPath: directory.path) { // 通过 fileManager.fileExists(atPath:) 检查 directory.path 是否已经存在。这一步是为了确保文件保存前,目录已经存在。
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) // 如果目标目录不存在,createDirectory(at:withIntermediateDirectories:) 方法会创建该目录。withIntermediateDirectories: true 参数表示即使中间目录不存在,也会创建。例如,如果 subfolder 和 folder 这两个中间目录不存在,它会自动创建这些中间目录。
}
扩展知识2
文件路径和文件夹路径的区分,文件夹路径一般会以 / 结尾。
例如:
let url = URL.documentsDirectory.appending(path: "folder/subfolder/")
第二部分
init() {
if let data = try? Data(contentsOf: savePath) {
if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
path = NavigationPath(decoded)
return
}
}
// Still here? Start with an empty path.
path = NavigationPath()
}
从初始化器init()读取NavigationPath数据。首先是使用Data尝试从savePath路径下读取数据,这个方法用于从文件路径(savePath)中加载二进制数据,如果路径指向的文件含有数据,它就会成功读取文件并将文件内容加载为Data类型的对象。
然后使用JSONDecoder()进行解码,解码后赋值给现有的path变量。
特别注意的是,我们使用的是NavigationPath.CodableRepresentation类型进行解码。
如果使用NavigationPath.self进行解码,就会解码失败,并且会提示你:
Instance method 'decode(_:from:)' requires that 'NavigationPath' conform to 'Decodable'
Instance method 'encode' requires that 'NavigationPath' conform to 'Encodable'
这是因为NavigationPath不是标准类型,没有符合Encodable和Decodable协议,任何需要被编码或解码的类型都必须遵循这两个协议。
NavigationPath.CodableRepresentation
NavigationPath.CodableRepresentation是NavigationPath的可编码(Codable)表示,它是NavigationPath的一种结构,用于将复杂的NavigationPath转换为可以被JSON编码/解码的简单数据结构。
所以,我们在解码NavigationPath时,应该使用NavigationPath.CodableRepresentation类型解码。
将解码的CodableRepresentation转换为原始的NavigationPath。
path = NavigationPath(decoded)
decoded从文件中恢复出来导航的路径数据,通过NavigationPath的构造器重新生成NavigationPath对象。
如果读取或解码数据失败,则从空的初始状态开始。
第三部分
func save() {
guard let representation = path.codable else { return }
do {
let data = try JSONEncoder().encode(representation)
try data.write(to: savePath)
} catch {
print("Failed to save navigation data")
}
}
Save方法的作用为将当前的path保存在磁盘上。
Path.codable是NavigationPath提供的一个属性,用来生成其可编码表示(即CodableRepresentation,与前面的解码类型一致)。这里使用guard let来确保path能正确生成可编码表示,如果无法生成则直接返回(跳过保存操作)。
如果path.codable非空,则继续执行下面的保存操作。
使用JSONEncoder()将path(的可编码表示)转码为JSON数据,尝试将数据写入savePath路径的文件中。如果之前没有savePath文件,只有在data.write(to:)操作时,才会创建该文件。
write(to:)是Data的一个方法,用于将字节序列写入磁盘文件。to:参数指定了文件保存的路径。使用try表示错误处理,因为write(to:)在写入文件时,可能发生磁盘已满、没有权限、文件系统问题等报错,所以会把它放到do-catch中捕获可能存在的问题。
总结
我们在本篇教学文章中学习了NavigationDestination以及NavigationPath。
NavigationDestination是根据导航路径涉及的数据类型来判断。
NavigationPath保存导航路径并支持多种类型,还允许被编解码保存在Documents文件夹中。
此外,额外的知识点还补充了关于NavigationPath.CodableRepresentation的类型和NavigationPath.codable可编码表示,也学习了NavigationPath(decoded)构造器恢复NavigationPath对象,还有data.write(to:)等将编码的数据写入到路径中去。
完整代码
import SwiftUI
struct DetailView: View {
var number: Int
var body: some View {
VStack {
NavigationLink("Go to Random Number", value: Int.random(in: 1...1000))
}
.navigationTitle("Number: \(number)")
}
}
struct 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
}
}
// Still here? Start with an empty path.
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("Failed to save navigation data")
}
}
}
struct Navigation: View {
@State private var pathStore = PathStore() // 跟踪导航路径
var body: some View {
NavigationStack(path: $pathStore.path) {
DetailView(number: 33)
.navigationDestination(for: Int.self) { i in
DetailView(number: i)
}
}
}
}
#Preview {
Navigation()
}