第83天 项目 16 第五部分
有一本经典的计算机科学书籍叫做《计算机程序的构造和解释》(Structure and Interpretation of Computer Programs),作者在序言中写下了一句重要的话:“编写程序时,首先要考虑让人能读懂,其次才是让机器能执行。”
这句话值得反复品读,因为它对我们编写软件的方式有着重大影响。为什么在JavaScript这类允许自由混合字符串、整数甚至数组的语言中,我们还要强制自己使用数据类型呢?为什么要给代码添加注释?为什么要尝试将代码拆分成函数?为什么要有访问控制?
所有这些问题,以及更多类似问题,都能用那句引言来回答:因为我们的目标始终应该是让自己和其他开发者清楚地理解我们的意图。运行代码的CPU并不关心数据类型、注释、访问控制等内容,但如果想编写可扩展、可测试且可维护的优秀软件,就需要制定一些规则。
所以,或许今天可以多思考一下自己的代码编写方式——添加更多注释是否能帮助你在几个月后回忆起相关内容?写出好的注释和写出好的代码一样,都是一种技能!
今天你需要完成三个主题的内容,包括编写“我的”标签页、扫描二维码、为应用添加滑动操作等。
- 生成二维码并放大
- 使用SwiftUI扫描二维码
- 通过滑动操作添加选项
生成二维码并放大
作者:Paul Hudson 2024年2月1日
Core Image可以让我们根据任意输入字符串生成二维码,而且生成速度非常快。不过,这里存在一个问题:生成的图像非常小,因为它的尺寸仅够显示其包含的数据像素。
将二维码放大很容易,但要让它看起来“美观”,还需要调整SwiftUI的图像插值方式。因此,在这一步中,我们会让用户在表单中输入姓名和电子邮件地址,利用这两项信息生成一个用于标识用户身份的二维码,并在放大二维码的同时避免其变得模糊。
我们之前已经创建了一个简单的MeView结构体作为占位视图,所以首先要做的就是添加两个文本字段及其对应的字符串绑定。
首先,添加以下两个新的状态属性,用于存储姓名和电子邮件地址:
@AppStorage("name") private var name = "Anonymous"
@AppStorage("emailAddress") private var emailAddress = "you@yoursite.com"在视图的body部分,我们将使用两个带有大字体的文本字段,这次要给文本字段附加一个小巧但实用的修饰符textContentType()——它会告诉iOS我们正在请求用户输入哪种类型的信息。这样一来,iOS就能为用户提供自动补全数据,从而提升应用的使用体验。
将当前的body内容替换为以下代码:
NavigationStack {
Form {
TextField("姓名", text: $name)
.textContentType(.name)
.font(.title)
TextField("电子邮件地址", text: $emailAddress)
.textContentType(.emailAddress)
.font(.title)
}
.navigationTitle("你的二维码")
}我们将使用姓名和电子邮件地址字段生成二维码,二维码是由黑白像素组成的方形图案,可被手机和其他设备扫描。Core Image内置了用于生成二维码的滤镜,由于你之前已经学习过如何使用Core Image滤镜,所以会发现这个过程非常相似。
首先,我们需要通过新的导入语句引入所有Core Image滤镜:
import CoreImage.CIFilterBuiltins其次,需要两个属性来存储活跃的Core Image上下文和Core Image二维码生成器滤镜的实例。因此,向MeView中添加以下两个属性:
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()接下来就是最关键的部分:生成二维码本身。如果你还有印象,使用Core Image滤镜时,需要提供一些输入数据,然后将输出的CIImage转换为CGImage,再将CGImage转换为UIImage。我们这里也会遵循相同的步骤,不过有几点需要注意:
- 滤镜的输入需要是字符串,但滤镜的实际输入参数类型是
Data,所以需要进行类型转换。 - 如果转换过程因任何原因失败,我们将返回SF Symbols中的“xmark.circle”图像。
- 理论上,SF Symbols是基于字符串类型的,所以存在无法读取该图像的可能,若出现这种情况,我们将返回一个空的
UIImage。
现在,向MeView结构体中添加以下方法:
func generateQRCode(from string: String) -> UIImage {
filter.message = Data(string.utf8)
if let outputImage = filter.outputImage {
if let cgImage = context.createCGImage(outputImage, from: outputImage.extent) {
return UIImage(cgImage: cgImage)
}
}
return UIImage(systemName: "xmark.circle") ?? UIImage()
}将所有功能封装在一个方法中,在SwiftUI中非常实用,因为这能让body属性中的代码尽可能简洁。实际上,我们可以直接通过Image(uiImage:)调用generateQRCode(from:)方法,然后在屏幕上将二维码放大到合适的尺寸——每当name或emailAddress发生变化时,SwiftUI都会确保该方法被调用。
至于传入generateQRCode(from:)方法的字符串,我们会使用用户输入的姓名和电子邮件地址,并用换行符分隔。这种格式简单直观,之后扫描这些二维码时也很容易反向解析。
在第二个文本字段的正下方添加以下新的Image视图:
Image(uiImage: generateQRCode(from: "\(name)\n\(emailAddress)"))
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)运行代码后,你会发现功能基本正常——会显示一个默认的二维码,同时你也可以在两个文本字段中输入内容,让二维码动态变化。
不过,仔细观察二维码,你会发现它有些模糊。这是因为Core Image生成的图像尺寸很小,而SwiftUI在放大图像时会尝试对像素进行平滑处理。
二维码和条形码这类线条图形非常适合禁用图像插值。尝试给图像添加以下修饰符,看看效果:
.interpolation(.none)现在二维码会显示得清晰锐利,因为SwiftUI会直接重复像素,而不是尝试对像素进行像素进行平滑混合。我认为相机扫描设备并不在意使用哪种方式,但对用户来说,清晰的显示效果更好!
使用SwiftUI扫描二维码
作者:Paul Hudson 2024年2月11日
扫描二维码(实际上还包括条形码等任何可见码)可以通过苹果的AVFoundation库实现。不过,AVFoundation与SwiftUI的集成并不是很顺畅,为了省去大量麻烦,我将二维码扫描器封装成了一个Swift包,你可以直接在Xcode中添加并使用。
这个包名为CodeScanner,基于MIT许可证托管在GitHub上,地址是https://github.com/twostraws/CodeScanner——你可以根据需要查看和/或编辑源代码。不过在这里,我们只需按照以下步骤将其添加到Xcode中:
- 点击“文件”(File)>“添加包依赖项”(Add Package Dependencies)。
- 输入https://github.com/twostraws/CodeScanner作为包仓库URL。
- 版本规则保持“最新主要版本以下”(Up to Next Major)选中状态,这样你将获得所有bug修复和新增功能更新,但不会收到破坏性更新。
- 点击“添加包”(Add Package),将完成的包导入到项目中。
CodeScanner包为我们提供了一个CodeScannerView SwiftUI视图,该视图可以在工作表(sheet)中展示,并以简洁、独立的方式处理二维码扫描。我知道我一直在重复,但希望你能发现其中的核心原则:编写SwiftUI的最佳方式是将功能封装在独立的方法和包装器中,这样暴露给SwiftUI布局的内容就会简洁、清晰且无歧义。
我们在ProspectsView中已经有一个“扫描”(Scan)按钮,我们将通过这个按钮触发二维码扫描功能。首先,向ProspectsView中添加以下新的@State属性:
@State private var isShowingScanner = false之前我们为“扫描”按钮添加了一些测试功能,用于插入示例数据,但现在不再需要了,因为我们即将扫描真实的二维码。因此,将工具栏按钮的动作代码替换为以下内容:
isShowingScanner = true在处理二维码扫描结果方面,CodeScanner包已经完成了所有工作,包括识别二维码内容以及将结果返回,所以我们只需捕获结果并进行相应处理即可。
当CodeScannerView识别到二维码时,会调用一个完成闭包,并传入一个Result实例——该实例要么包含识别到的二维码详情,要么包含错误信息(例如相机不可用、相机无法扫描二维码等)。无论返回的是二维码信息还是错误信息,我们都只需关闭视图;后续我们会添加更多代码来实现其他功能。
首先,在ProspectsView.swift文件顶部附近添加以下新的导入语句:
import CodeScanner然后向ProspectsView中添加以下方法:
func handleScan(result: Result<ScanResult, ScanError>) {
isShowingScanner = false
// 后续将添加更多代码
}在展示扫描器并处理其结果之前,我们需要请求用户允许使用相机:
- 进入目标的配置选项,选择“信息”(Info)标签页。
- 右键点击现有键,选择“添加行”(Add Row)。
- 为键选择“隐私 - 相机使用说明”(Privacy - Camera Usage Description)。
- 在值中输入“我们需要扫描二维码。”(We need to scan QR codes.)
现在,我们已经准备好扫描二维码了!我们已经有了isShowingScanner状态,用于决定是否展示二维码扫描器,因此现在可以添加一个sheet()修饰符来展示扫描器界面。
创建CodeScannerView至少需要三个参数:
- 我们想要扫描的码类型数组。本应用中我们只扫描二维码,所以使用
[.qr]即可,但iOS实际上支持多种其他码类型。 - 用于模拟数据的字符串。Xcode模拟器不支持使用相机扫描二维码,因此
CodeScannerView会自动展示一个替代界面,方便我们测试功能。这个替代界面会自动返回我们传入的模拟数据。 - 要使用的完成函数。可以使用闭包,但我们刚刚编写了
handleScan()方法,所以直接使用该方法即可。
因此,在ProspectsView中现有的toolbar()修饰符下方添加以下代码:
.sheet(isPresented: $isShowingScanner) {
CodeScannerView(codeTypes: [.qr], simulatedData: "Paul Hudson\npaul@hackingwithswift.com", completion: handleScan)
}这样就能让这个界面的大部分功能正常工作了,但还有最后一步:将handleScan()方法中“// 后续将添加更多代码”的注释替换为实际处理识别到的数据的代码。
回想一下,我们生成的二维码格式是“姓名+换行符+电子邮件地址”,因此如果扫描结果成功返回,我们可以将识别到的字符串按换行符拆分成两部分,并用这两部分内容创建一个新的Prospect实例。如果扫描失败,我们只需打印错误信息——你也可以根据需要展示更直观的界面提示!
将“// 后续将添加更多代码”的注释替换为以下代码:
switch result {
case .success(let result):
let details = result.string.components(separatedBy: "\n")
guard details.count == 2 else { return }
let person = Prospect(name: details[0], emailAddress: details[1], isContacted: false)
modelContext.insert(person)
case .failure(let error):
print("扫描失败:\(error.localizedDescription)")
}现在运行代码。如果使用模拟器,会看到一个测试界面,点击任意位置都会关闭该界面并返回我们设置的模拟数据。
如果使用真实设备,会看到一个请求允许使用相机的权限提示,允许后会显示扫描界面。要在真实设备上测试扫描功能,可以同时在模拟器中启动应用并切换到“我的”(Me)标签页——你的手机应该能够扫描电脑上模拟器屏幕显示的二维码。
通过滑动操作添加选项
作者:Paul Hudson 2024年2月11日
我们需要一种方法将联系人在“已联系”(Contacted)和“未联系”(Uncontacted)标签页之间移动,最简单的方式是在ProspectsView的VStack中添加滑动操作。这样用户就可以在列表中滑动任意联系人条目,然后点击一个选项就能将其在两个标签页之间移动。
需要注意的是,这个视图在三个地方共用,所以无论在哪个地方使用,滑动操作都要显示正常。我们“本可以”尝试使用大量三元条件运算符,但之后我们会添加第二个按钮,所以三元运算符的方式并不适用。相反,我们只需在一个简单的条件语句中包裹按钮——现在就向VStack中添加以下代码:
.swipeActions {
if prospect.isContacted {
Button("标记为未联系", systemImage: "person.crop.circle.badge.xmark") {
prospect.isContacted.toggle()
}
.tint(.blue)
} else {
Button("标记为已联系", systemImage: "person.crop.circle.fill.badge.checkmark") {
prospect.isContacted.toggle()
}
.tint(.green)
}
}得益于SwiftUI和SwiftData的紧密集成,只需调用属性的toggle()方法,就能翻转布尔值、更新视图,并将更改保存到持久化存储中——仅用一行代码就能完成所有操作。
现在运行应用,你会发现功能运行良好——扫描一个用户,然后打开上下文菜单并点击相应操作,就能看到该用户在“已联系”和“未联系”标签页之间移动。
滑动操作是为SwiftUI应用添加额外功能的好方法,但它与我们之前使用的onDelete()修饰符“不兼容”。因此,我们需要手动实现删除功能,并且会同时采用两种方式:一种是像之前onDelete()那样的单个条目滑动删除,另一种是允许多选删除,即用户可以同时删除多个条目。
实现滑动删除的等效功能很简单,只需在现有的滑动操作中再添加一个按钮即可。确保将这个按钮添加在按钮列表的“最前面”,这样它会自动获得“完全滑动即可激活”的功能:
Button("删除", systemImage: "trash", role: .destructive) {
modelContext.delete(prospect)
}提示: 单独使用时,常规的滑动删除操作会显示“删除”(Delete)文字,而不是垃圾桶图标;但当与其他滑动操作一起使用时,苹果建议添加图标,以避免图标和文字混用。
我们要添加的第二种方式是允许用户同时选中多个行,并一次性删除它们。这意味着需要添加一些新的本地状态来存储用户选中的条目:
@State private var selectedProspects = Set<Prospect>()然后将这个选中状态绑定到列表:
List(prospects, selection: $selectedProspects) { prospect in重要提示: 为了让SwiftUI理解列表中的每一行都对应一个单独的prospect,需要在滑动操作之后添加以下代码:
.tag(prospect)现在,我们只需确定构建哪种界面来实现选中和删除功能即可。有几种不同的选择,你当然可以尝试不同的方式,但我认为最简单的是在导航栏的左上角显示一个编辑按钮——这是苹果官方应用中编辑按钮的常用位置。然后,我们可以添加第二个按钮来激活批量删除功能,苹果通常会将这个按钮放在底部的专用工具栏中。
首先,需要编写一个方法来删除所有选中的行:
func delete() {
for prospect in selectedProspects {
modelContext.delete(prospect)
}
}然后,我们可以在现有的toolbar()修饰符中添加两个新的工具栏项,第一个用于创建编辑按钮:
ToolbarItem(placement: .topBarLeading) {
EditButton()
}第二个用于创建删除按钮,但仅在有选中条目时显示:
if selectedProspects.isEmpty == false {
ToolbarItem(placement: .bottomBar) {
Button("删除选中项", action: delete)
}
}这样设置后,用户就有两种方式来执行删除操作:一种是他们熟悉的滑动删除,另一种是清晰的“编辑/完成”(Edit/Done)按钮,这种方式更易于发现。
重要提示: 由于我们现在在工具栏中使用ToolbarItem,因此需要将之前的代码也包裹在ToolbarItem中,如下所示:
ToolbarItem(placement: .topBarTrailing) {
Button("扫描", systemImage: "qrcode.viewfinder") {
isShowingScanner = true
}
}现在运行应用——你应该可以自由地添加和删除prospect了!