Skip to content

第82天 项目 16 第四部分

是时候将新学到的技巧付诸实践了,这个项目规模较大,需要三天天时间来完成实现。不过今天是第82天,事实已经证明你有毅力做出出色的成果——就像航空先驱阿米莉亚·埃尔哈特曾经说过的:“最困难的事情是下定决心行动,剩下的不过是坚持不懈。”

今天我们要回到SwiftData的学习中,因为这款这款这款应用非常适合用它来存储数据。我知道你可能会觉得我们只是在重复之前的内容,但请相信我:重复是学习过程中最重要的环节之一,而且我在今天和之前SwiftData相关内容的学习之间留出了不少天的间隔,特意这样安排是因为增加这段额外的时间有助于知识的巩固。(如果你好奇,这种方法叫做“间隔重复”——很管用的!)

好了,闲话不多说,今天你有很多任务要完成,我们赶紧开始看代码吧。

今天你需要完成三个主题的学习,我们会涵盖标签视图、SwiftData过滤等更多内容。

  • 构建标签栏
  • 使用SwiftData存储数据
  • 动态过滤SwiftData查询结果

这款应用将在标签栏中显示四个SwiftUI视图:一个用于展示你认识的所有人,一个用于展示你已经联系过的人,另一个用于展示你尚未联系过的人,最后一个则展示你的个人信息,方便他人扫描。

前三个视图在概念上大同小异,但最后一个视图则完全不同。因此,我们只需用三个视图就能呈现所有的用户界面:一个用于展示联系人,一个用于展示个人信息,还有一个通过TabView将其他视图整合在一起。

所以,第一步我们要为各个标签创建占位视图,之后再回来完善它们。按下Cmd+N组合键,新建一个SwiftUI视图并命名为“ProspectsView”,然后再新建一个SwiftUI视图,命名为“MeView”。目前可以暂时保留这两个视图默认的“Hello, World!”文本视图,暂时不需要修改。

现在,关键在于ContentView,因为我们要在这个视图中创建TabView,并将所有其他视图包含在其中。很快我们会在这个视图中添加更多逻辑,但目前,它只需是一个包含三个ProspectsView实例和一个MeView实例的TabView即可。每个视图都会添加tabItem()修饰符,其中包含我从SF Symbols中挑选的图标和相应的文本。

将你当前ContentView的body部分替换为以下代码:

swift
TabView {
    ProspectsView()
        .tabItem {
            Label("所有人", systemImage: "person.3")
        }
    ProspectsView()
        .tabItem {
            Label("已联系", systemImage: "checkmark.circle")
        }
    ProspectsView()
        .tabItem {
            Label("未联系", systemImage: "questionmark.diamond")
        }
    MeView()
        .tabItem {
            Label("我的信息", systemImage: "person.crop.square")
        }
}

现在运行应用,你会看到屏幕底部出现一个简洁的标签栏,点击标签栏就能在四个视图之间切换。

显然,创建三个ProspectsView实例在实际使用中会显得很奇怪,因为它们完全相同,但我们可以通过自定义每个视图来解决这个问题。记住,我们希望第一个视图展示你认识的所有人,第二个展示你已经联系过的人,第三个展示你尚未联系过的人,要实现这一点,我们可以定义一个枚举,并在ProspectsView中添加一个相关属性。

现在,在ProspectsView内部添加以下枚举:

swift
enum FilterType {
    case none, contacted, uncontacted
}

接下来,我们可以利用这个枚举,通过为ProspectsView添加一个新属性,让每个ProspectsView实例都有所不同:

swift
let filter: FilterType

这一修改会立即导致ContentView及其预览无法编译,因为创建ProspectsView实例时需要为该属性提供一个值。不过,我们先利用这个属性对三个视图进行一些自定义,为它们设置不同的导航栏标题。

首先,在ProspectsView中添加以下计算属性:

swift
var title: String {
    switch filter {
    case .none:
        "所有人"
    case .contacted:
        "已联系的人"
    case .uncontacted:
        "未联系的人"
    }
}

然后,将默认的“Hello, World!”文本视图替换为以下内容:

swift
NavigationStack {
    Text("Hello, World!")
        .navigationTitle(title)
}

这样至少能让每个ProspectsView实例在外观上有所区别,便于我们确认标签栏是否正常工作。

要让代码重新编译,我们需要确保调用每个ProspectsView初始化器时都传入一个filter值。因此,将预览代码修改为:

swift
ProspectsView(filter: .none)

接着,修改ContentView中的三个ProspectsView实例,分别为它们设置filter: .nonefilter: .contactedfilter: .uncontacted

现在运行应用,看起来会好很多。接下来,我们引入一些数据……

使用SwiftData存储数据

作者:Paul Hudson 2024年2月1日

很多应用都非常适合使用SwiftData,而且大多数情况下,只需很少的工作量就能完成相关设置。

在我们的应用中,TabView包含三个ProspectsView实例,我们希望这三个实例都能操作同一组共享数据。用SwiftData的术语来说,这意味着它们都访问同一个模型上下文(model context),但使用略有不同的查询条件。

首先,新建一个名为Prospect.swift的Swift文件,将文件中默认导入的Foundation替换为SwiftData,然后添加以下代码:

swift
@Model
class Prospect {
    var name: String
    var emailAddress: String
    var isContacted: Bool
}

添加完上述代码后,在isContacted属性下方输入“in”,Xcode会自动补全初始化方法。

记住,SwiftData的@Model宏只能用于类,但这意味着我们可以在多个视图中共享该类的实例,从而确保所有视图都能自动保持数据同步。

现在我们已经有了要存储的数据模型,接下来可以告诉SwiftData为其创建一个模型容器(model container)。具体操作是打开HotProspectsApp.swift文件,导入SwiftData,然后添加modelContainer(for:)修饰符,代码如下:

swift
WindowGroup {
    ContentView()
}
.modelContainer(for: Prospect.self)

这行代码不仅为Prospect类创建了存储,还会在应用的所有SwiftUI视图中注入一个共享的SwiftData模型上下文,只需一行代码就能完成这些操作。

我们希望所有ProspectsView实例都能共享这些模型数据,这样它们操作的就是同一组底层数据。要实现这一点,需要添加两个属性:一个用于访问刚才创建的模型上下文,另一个用于执行Prospect对象的查询。

现在打开ProspectsView.swift文件,导入SwiftData,然后在ProspectsView结构体中添加以下两个新属性:

swift
@Query(sort: \Prospect.name) var prospects: [Prospect]
@Environment(\.modelContext) var modelContext

提示: 如果打算使用Xcode的预览功能,请在预览代码中添加modelContainer(for: Prospect.self)

这就是全部操作了——我认为SwiftData已经无法再简化这个过程了。

很快我们会添加通过扫描二维码来添加联系人的代码,但目前,我们先添加一个导航栏按钮,用于添加测试数据并在屏幕上显示。

ProspectsViewbody属性修改为以下内容:

swift
NavigationStack {
    Text("联系人数量:\(prospects.count)")
        .navigationTitle(title)
        .toolbar {
            Button("扫描", systemImage: "qrcode.viewfinder") {
                let prospect = Prospect(name: "保罗·哈德森", emailAddress: "paul@hackingwithswift.com", isContacted: false)
                modelContext.insert(prospect)
            }
        }
}

现在,在标签视图的前三个视图中,你会看到一个“扫描”按钮,点击该按钮会同时向三个视图中添加一个联系人——无论点击哪个视图中的按钮,你都会看到联系人数量增加。

动态过滤SwiftData查询结果

作者:Paul Hudson 2024年2月1日

我们的基础SwiftData查询代码如下:

swift
@Query(sort: \Prospect.name) var prospects: [Prospect]

默认情况下,该查询会加载所有Prospect模型对象,并按名称排序。对于“所有人”标签来说,这样的查询足够了,但对于另外两个标签则不够。

在我们的应用中,三个ProspectsView实例的区别仅在于从标签视图传入的FilterType属性值。我们已经利用这个属性来设置每个视图的标题,现在还可以用它来过滤查询结果。

没错,我们已经有了一个默认的查询,但通过添加初始化方法,我们可以在设置过滤条件时覆盖默认查询。

现在为ProspectsView添加以下初始化方法:

swift
init(filter: FilterType) {
    self.filter = filter

    if filter != .none {
        let showContactedOnly = filter == .contacted

        _prospects = Query(filter: #Predicate {
            $0.isContacted == showContactedOnly
        }, sort: [SortDescriptor(\Prospect.name)])
    }
}

之前我们已经学习过如何手动创建查询,但有一行代码特别值得注意:

swift
let showContactedOnly = filter == .contacted

如果你对这行代码感到困惑,可以将其拆分为两部分来理解。首先是这个判断:

swift
filter == .contacted

如果filter的值等于.contacted,该判断会返回true,否则返回false。然后是这部分:

swift
let showContactedOnly =

这行代码会将filter == .contacted的结果赋值给一个名为showContactedOnly的新常量。所以,整行代码的意思是“如果过滤条件设置为.contacted,就将showContactedOnly设为true”。这样一来,我们的SwiftData谓词(predicate)就变得很简单了,只需将这个常量与isContacted直接进行比较即可。

添加完这个初始化方法后,我们就可以创建一个List来遍历查询得到的数组。我们将使用VStack来显示每个联系人的姓名和电子邮件地址,将ProspectsView中现有的文本视图替换为以下代码:

swift
List(prospects) { prospect in
    VStack(alignment: .leading) {
        Text(prospect.name)
            .font(.headline)
        Text(prospect.emailAddress)
            .foregroundStyle(.secondary)
    }
}

再次运行应用,你会发现应用的外观已经有了很大改善。

本站使用 VitePress 制作