Skip to content

第55天 项目 11 第三部分

今天我们完成这款应用时,希望你能停下来想一想,自己已经掌握了多少 SwiftUI 知识。例如,构建应用的详情页会用到 SwiftData、VStack、ZStack、裁剪形状、间隔器等组件——这些内容你现在应该已经非常熟悉了,这足以说明你取得了多大的进步。

不过,总有新的知识需要学习,今天我们将学习如何删除 SwiftData 对象、如何使用 SortDescriptor 对查询结果进行排序,以及如何为警告框添加自定义按钮。正如美国哲学家弗农·霍华德所说:“永远以一种还有新东西要学的心态面对生活,你一定会有所收获”——我会在项目中加入这些小知识点来让你保持专注,但复习旧知识往往也同样重要!

今天你需要完成三个主题的学习,通过这些内容,你将为应用添加排序、删除等功能。

  • 展示书籍详情
  • 使用 SortDescriptor 对 SwiftData 查询结果进行排序
  • 从 SwiftData 查询结果中删除数据
  • 使用警告框以编程方式关闭 NavigationLink

又一款应用完成了——继续加油!

展示书籍详情

作者:Paul Hudson 2023年11月19日

当用户在 ContentView 中点击某本书时,我们将展示一个详情视图,其中包含更多信息——书籍的类型、简短评论等。我们还会复用之前创建的 RatingView,并对其进行自定义,让你看看 SwiftUI 有多灵活。

为了让这个页面更有趣,我们将为应用中的每个书籍类别添加对应的图片。我已经从 Unsplash 上挑选了一些图片,并将它们放在了本书的 project11-files 文件夹中——如果你还没有下载这些文件,请立即下载并将它们拖入资源目录中。

Unsplash 的许可协议允许我们将图片用于商业或非商业用途,无论是否注明出处,不过注明出处会更受欢迎。我添加的这些图片分别由瑞安·华莱士、尤金·特里古巴、杰米·斯特里特、阿尔瓦罗·塞拉诺、若昂·西拉斯、大卫·迪尔伯特和凯西·霍纳拍摄——如果你想获取原图,可以访问 https://unsplash.com

接下来,创建一个名为“DetailView”的新 SwiftUI 视图,然后导入 SwiftData。这个新视图只需要一个属性,即它要展示的书籍,现在请添加这个属性:

swift
let book: Book

仅仅添加这个属性就会导致 DetailView.swift 底部的预览代码报错。以前遇到这种情况很容易修复,只需传入一个示例对象即可,但涉及到 SwiftData 时情况会更复杂:创建一本新书意味着还需要一个视图上下文来承载它。

这是 SwiftData 中第一个真正需要注意的点;我们必须把所有细节都处理正确,它才能正常工作:

  1. 要创建一个示例 Book 对象,必须先创建一个模型上下文。
  2. 模型上下文来自于创建的模型容器。
  3. 如果创建模型容器,我们不希望它实际存储任何数据,因此可以创建一个自定义配置,告诉 SwiftData 仅在内存中存储信息。这样所有数据都是临时的。

听起来步骤很多,但实际上只需几行代码——我们需要手动创建模型容器,并使用一个名为 ModelConfiguration 的新类型来请求临时内存存储。完成这些后,就可以像往常一样创建 Book 对象,然后将其与模型容器一起传入 DetailView。

将当前的预览代码替换为以下内容:

swift
#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: Book.self, configurations: config)
        let example = Book(title: "Test Book", author: "Test Author", genre: "Fantasy", review: "This was a great book; I really enjoyed it.", rating: 4)

        return DetailView(book: example)
            .modelContainer(container)
    } catch {
        return Text("Failed to create preview: \(error.localizedDescription)")
    }
}

是的,创建 Book 实例时并没有提到模型容器或配置,但这两者其实很重要:如果没有对应的容器就尝试创建 SwiftData 模型对象,代码很可能会崩溃。

解决完预览问题后,我们就可以把注意力转向更有趣的事情——设计视图本身。首先,我们将书籍类别图片和类别名称放在 ZStack 中,这样就能很好地将它们叠加在一起显示。我已经设计了一套我认为不错的样式,但你完全可以随意调整样式——不过我建议你一定要保留 ScrollView,因为无论评论有多长、用户使用什么设备,或者用户是否调整了字体大小,ScrollView 都能确保评论完全显示在屏幕上。

将当前的 body 属性替换为以下内容:

swift
ScrollView {
    ZStack(alignment: .bottomTrailing) {
        Image(book.genre)
            .resizable()
            .scaledToFit()

        Text(book.genre.uppercased())
            .font(.caption)
            .fontWeight(.black)
            .padding(8)
            .foregroundStyle(.white)
            .background(.black.opacity(0.75))
            .clipShape(.capsule)
            .offset(x: -5, y: -5)
    }
}
.navigationTitle(book.title)
.navigationBarTitleDisplayMode(.inline)
.scrollBounceBehavior(.basedOnSize)

这段代码会将类别名称放在 ZStack 的右下角,为其添加背景色、粗体字体和少量内边距,使其更醒目。

在这个 ZStack 下方,我们将添加作者、评论和评分信息。我们不希望用户在这里调整评分,因此可以使用一个常量绑定,将 RatingView 变成一个简单的只读视图。更棒的是,由于我们使用 SF Symbols 来创建评分图片,只需添加一个简单的 font() 修饰符,就能无缝放大图片,更好地利用可用空间。

因此,在之前的 ZStack 正下方添加以下视图:

swift
Text(book.author)
    .font(.title)
    .foregroundStyle(.secondary)

Text(book.review)
    .padding()

RatingView(rating: .constant(book.rating))
    .font(.largeTitle)

至此,DetailView 就完成了,接下来回到 ContentView.swift,为 List 视图添加一个导航目标:

swift
.navigationDestination(for: Book.self) { book in
    DetailView(book: book)
}

现在再次运行应用,你应该可以点击已添加的任何书籍,在新的详情视图中查看它们的信息了。

使用 SortDescriptor 对 SwiftData 查询结果进行排序

作者:Paul Hudson 2023年11月19日

当使用 @Query 从 SwiftData 中获取对象时,你可以指定数据的排序方式——是按某个字段的字母顺序排序,还是按数字从大到小排序?无论选择哪种方式,最好都指定一种排序方式,这样用户就能获得可预测的使用体验。

在这个项目中,有多个字段可用于排序:书籍的标题、作者或评分都是合理的选择,但我们不必只依赖一个字段——你可以指定多个排序字段,例如先按评分从高到低排序,然后以书名作为平局决胜因素。

查询排序有两种方式:一种简单方式,只允许按一个字段排序;另一种更高级的方式,允许使用一个名为 SortDescriptor 的新类型的数组。

使用简单方式时,我们可以要求书籍按标题的字母顺序排列:

swift
@Query(sort: \Book.title) var books: [Book]

或者,我们可以要求按评分从高到低排序:

swift
@Query(sort: \Book.rating, order: .reverse) var books: [Book]

这种方式在只需按单个字段排序时效果很好,但通常来说,最好再设置一个备用字段——比如“先按评分排序,再按标题排序”,这样能让应用的使用体验更具可预测性,这总是一件好事。

这需要通过 SortDescriptor 类型来实现,我们可以用一个或两个值来创建它:要排序的属性,以及(可选的)是否反向排序。例如,我们可以像这样按标题的字母顺序排序:

swift
@Query(sort: [SortDescriptor(\Book.title)]) var books: [Book]

与简单排序方式一样,使用 SortDescriptor 对结果进行排序时,默认按升序排列(文本按字母顺序,数字从小到大),但如果想反向排序,可以使用以下代码:

swift
@Query(sort: [SortDescriptor(\Book.title, order: .reverse)]) var books: [Book]

你可以指定多个排序描述符,它们会按照你提供的顺序生效。例如,如果用户先添加了皮特·哈米尔所著的《Forever》,之后又添加了朱迪·布鲁姆所著的《Forever》——这是两本完全不同的书,只是书名碰巧相同——这时指定第二个排序字段就很有帮助。

因此,我们可以先按书籍标题升序排序,再按书籍作者升序排序,代码如下:

swift
@Query(sort: [
    SortDescriptor(\Book.title),
    SortDescriptor(\Book.author)
]) var books: [Book]

除非有大量具有相似值的数据,否则添加第二个甚至第三个排序字段对性能几乎没有影响。例如,在我们的书籍数据中,几乎每本书的标题都是唯一的,因此从性能角度来看,添加次要排序字段几乎无关紧要。

从 SwiftData 查询结果中删除数据

作者:Paul Hudson 2023年11月19日

我们已经使用 @Query 将 SwiftData 对象放入 SwiftUI 的 List 中,只需再做一点工作,就能同时启用滑动删除功能和专门的“编辑/完成”按钮。

与处理普通数据数组一样,大部分工作是通过给 ForEach 添加 onDelete(perform:) 修饰符来完成的,但与直接从数组中删除项目不同,我们需要在查询结果中找到要删除的对象,然后使用该对象在模型上下文中调用 delete() 方法。删除所有对象后,SwiftData 的自动保存系统会启动,并将这些更改永久应用。

首先,在 ContentView 中添加以下方法:

swift
func deleteBooks(at offsets: IndexSet) {
    for offset in offsets {
        // 在查询结果中找到这本书
        let book = books[offset]

        // 从上下文中删除它
        modelContext.delete(book)
    }
}

我们可以通过给 ContentView 中的 ForEach 添加 onDelete(perform:) 修饰符来触发这个方法,但要记住:修饰符需要添加在 ForEach 上,而不是 List 上。

现在添加这个修饰符:

swift
.onDelete(perform: deleteBooks)

这样就实现了滑动删除功能,我们还可以更进一步,添加一个“编辑/完成”按钮。在 ContentView 中找到 toolbar() 修饰符,然后添加另一个 ToolbarItem:

swift
ToolbarItem(placement: .topBarLeading) {
    EditButton()
}

至此,ContentView 就完成了,尝试运行应用吧——现在你可以自由添加和删除书籍,既可以通过滑动删除,也可以使用编辑按钮删除。

作者:Paul Hudson 2023年11月19日

你已经了解到,NavigationLink 可以让我们跳转到详情页,详情页既可以是自定义视图,也可以是 SwiftUI 的内置类型(如 Text 或 Image)。由于我们处于 NavigationStack 中,iOS 会自动提供一个“返回”按钮,让用户回到上一个页面,用户也可以从左边缘滑动来返回。不过,有时候以编程方式返回会很有用——也就是说,在我们需要的时候(而不是用户滑动的时候)回到上一个页面。

我们之前已经讨论过这个话题,希望这次对你来说只是一次很好的练习:我们将为应用添加最后一个功能,即删除用户当前查看的书籍。要实现这个功能,我们需要显示一个警告框,询问用户是否确定要删除该书籍,如果用户确认,就从当前的模型上下文中删除该书籍。删除完成后,当前页面就没有存在的必要了(因为它关联的书籍已经不存在了),所以我们要关闭当前视图——将其从 NavigationStack 的栈顶移除,从而回到上一个页面。

首先,我们需要在 DetailView 结构体中添加三个新属性:一个用于存储 SwiftData 模型上下文(以便删除数据),一个用于存储关闭操作(以便将视图从导航栈中移除),还有一个用于控制是否显示删除确认警告框。

因此,首先在 DetailView 中添加以下三个新属性:

swift
@Environment(\.modelContext) var modelContext
@Environment(\.dismiss) var dismiss
@State private var showingDeleteAlert = false

第二步是编写一个方法,从模型上下文中删除当前书籍,并关闭当前视图。无论这个视图是通过 navigation link 还是 sheet 显示的,我们都使用相同的 dismiss() 代码。

现在在 DetailView 中添加这个方法:

swift
func deleteBook() {
    modelContext.delete(book)
    dismiss()
}

第三步是添加一个 alert() 修饰符,监听 showingDeleteAlert 的值,并询问用户是否确认删除操作。到目前为止,我们使用的都是只有一个关闭按钮的简单警告框,但这里我们需要两个按钮:一个用于删除书籍,另一个用于取消操作。这两个按钮都有特定的按钮角色,可以自动呈现正确的样式,所以我们会使用这些角色。

苹果对警告框文本的标注有非常明确的指导原则,核心内容如下:如果是简单的“我明白了”之类的确认,使用“确定”(OK)就可以;但如果需要用户做出选择,就应该避免使用“是”(Yes)和“否”(No)这样的标题,而是使用动词,如“忽略”(Ignore)、“回复”(Reply)和“确认”(Confirm)。

在这个例子中,我们将为破坏性按钮使用“删除”(Delete),然后在旁边提供一个取消按钮,让用户在不想删除时可以退出操作。因此,给 DetailView 中的 ScrollView 添加以下修饰符:

swift
.alert("Delete book", isPresented: $showingDeleteAlert) {
    Button("Delete", role: .destructive, action: deleteBook)
    Button("Cancel", role: .cancel) { }
} message: {
    Text("Are you sure?")
}

最后一步是添加一个工具栏项,启动删除流程——只需翻转 showingDeleteAlert 的布尔值即可,因为我们的 alert() 修饰符已经在监听这个值了。因此,给 ScrollView 添加最后一个修饰符:

swift
.toolbar {
    Button("Delete this book", systemImage: "trash") {
        showingDeleteAlert = true
    }
}

现在,你既可以在 ContentView 中通过滑动删除或编辑按钮删除书籍,也可以导航到 DetailView,然后点击其中专门的删除按钮——点击后会删除书籍、更新 ContentView 中的列表,然后自动关闭详情视图。

又一款应用完成了——做得好!

本站使用 VitePress 制作