Swift UI 深入理解NavigationDestination 和 NavigationPath
Swift UI 深入理解NavigationDestination 和 NavigationPath

Swift UI 深入理解NavigationDestination 和 NavigationPath

文章介绍

本篇文章主要讲述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)
}
  1. DataType.self表示要导航的路径中涉及的数据类型。这里跟JSONDecoder解码的数据类型一样,在解码的类型后面添加.self。
  2. Item:表示传递给目标视图的数据
  3. 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)显示对应的目标视图。

示例

  1. 假设path为初始空数组[],用户点击按钮后,Int.random(in:1…1000)随机为8,此时path更新为[8],视图跳转到DetailView(number:8)。
  2. 当用户再次点击按钮后,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()
}

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注