第17章:与UIKit的接口¶
有时你会需要一个SwiftUI中没有的用户界面功能。UIKit是一个开发用户界面的框架,从2008年的第一部iPhone开始就已经存在了。作为一个成熟的框架,它的复杂性和范围在这些年里不断增长,SwiftUI需要一些时间来追赶。有了UIKit,除其他外,你可以处理铅笔互动,支持指针行为,做更复杂的触摸和手势识别,以及任何数量的图像和颜色操作。
有了UIKit作为SwiftUI的备份,你可以实现任何你能想到的界面设计。你已经使用UIImage将图像加载到SwiftUI的Image视图中,使用UIViewRepresentable协议使用任何UIKit视图来代替SwiftUI视图几乎同样简单。
本章将介绍从你的应用程序外部加载图像和照片。首先,你将从你的照片库中加载照片,然后你将从其他应用程序(如Safari)拖动或复制图片。
UIKit¶
UIKit有一个与SwiftUI完全不同的数据流范式。在SwiftUI中,你定义了View和一个真实源。当真理之源发生变化时,视图会自动更新。UIView没有绑定数据的概念,所以当数据发生变化时,你必须明确地更新视图。

只要你能为新的应用程序,你应该坚持使用SwiftUI。然而,UIKit确实有一些有用的框架,如果不使用其中一个框架,往往就无法完成一些事情。PhotoKit提供了对Photos应用中的照片和视频的访问,而PhotosUI框架提供了一个用于资产选择的用户界面。
使用Representable协议¶
Representable协议是你在SwiftUI应用程序中插入UIKit视图的方式。你不是为SwiftUI视图创建一个符合View的结构,而是创建一个符合UIViewRepresentable的结构--用于单个视图--或UIViewControllerRepresentable,如果你想使用视图控制器对视图进行复杂管理。为了接收来自UIKit视图的信息,你创建一个协调员。

为了开始使用UIViewRepresentable,你首先要在一个简单的SwiftUI View之上展示一个基本的、彩色的UIView。
➤ 在组卡模态视图中,创建一个名为PhotoPicker.swift的新Swift文件。
➤ 将代码替换为:
import SwiftUI
struct PhotoPicker: UIViewRepresentable {
}
在这里,你创建了一个结构,它将符合协议 UIViewRepresentable。
➤ 有两个必要的方法,所以添加这些:
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.text = "Hello UIKit!"
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
}
makeUIView(context:)是创建并返回一个UIView--在本例中是一个UILabel,它只是显示一些文本。
updateUIView(_:context:)是父视图与子视图交流的地方。在这种情况下,子视图是一个只读的标签,而且没有通信。
➤ 创建一个预览,显示SwiftUI Text视图:
struct PhotoPicker_Previews: PreviewProvider {
static var previews: some View {
Text("Hello SwiftUI!")
.background(Color.yellow)
}
}
➤ 预览这个,然后把Text("Hello SwiftUI!")替换成:
PhotoPicker()
➤ 预览视图。

SwiftUI的文本视图只占用了足够的空间来显示自己,而UIKit的视图将占用整个可用空间。
UIKit委托模式¶
许多UIKit类使用协议,要求一个委托对象来处理事件。例如,在创建UITableView时,你指定了一个实现UITableViewDelegate的类,并管理当用户选择表格中的一行时应用程序应该做什么。

使用委托,你可以改变表格的行为而不需要子类化UITableView。
在PhotoPicker中,你的代码将使用系统的照片挑选器:PHPickerViewController。这个类在UIView中显示用户的照片,并允许用户从库中选择照片。当用户点击添加或取消时,会发生一个事件。当这种情况发生时,PHPickerViewController将通知委托对象,而委托对象可以采取任何行动。这个委托对象必须是NSObject的子类,并且符合PHPickerViewControllerDelegate。
PhotoPicker是一个结构,所以它不能符合PHPickerViewControllerDelegate。在PhotoPicker中,你将创建一个协调器类,它将与系统的照片挑选器对接,并通过PhotoPicker将所有选择的图片返回给CardDetailView。

采摘照片¶
由于系统的照片挑选器是UIViewController的子类,你的Representable结构将是UIViewControllerRepresentable。
➤ 在PhotoPicker.swift的顶部导入照片框架:
import PhotosUI
➤ 将PhotoPicker替换为:
struct PhotoPicker: UIViewControllerRepresentable {
func makeUIViewController(context: Context)
-> some UIViewController {
}
func updateUIViewController(
_ uiViewController: UIViewControllerType,
context: Context
) {
}
}
这是符合UIViewControllerRepresentable的两个方法。
➤ 在makeUIViewController(context:)中,添加照片挑选器:
// 1
var configuration = PHPickerConfiguration()
configuration.filter = .images
// 2
configuration.selectionLimit = 0
// 3
let picker =
PHPickerViewController(configuration: configuration)
return picker
翻阅代码:
- 你创建一个
PHPickerConfiguration。这个配置有一个过滤器,你可以指定照片的类型供用户选择。除了images,你可以选择livePhotos和videos。 - 指定允许用户选择的照片数量。使用
0来允许选择多张照片。 - 使用之前创建的
configuration创建一个PHPickerViewController。
➤ 在PhotoPicker中创建一个新的类。
class PhotosCoordinator: NSObject,
PHPickerViewControllerDelegate {
}
这个类不需要是PhotoPicker的内部,但是你可能不想单独使用它,让它成为内部意味着它只能在PhotoPicker的范围内。
PhotosCoordinator是NSObject的一个子类。这是大多数Objective-C对象继承的类,所以当你使用UIKit对象的类委托时,你通常会继承NSObject。
➤ 为PHPickerViewControllerDelegate添加所需的方法:
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
}
当用户在选择照片后点击添加,PHPickerViewController将调用这个委托方法,并在PHPickerResult对象的数组中以UIImage的形式传递所选照片。你将处理这些图片中的每一张。
➤ 给PhotoPicker添加一个新的属性(不是PhotosCoordinator):
@Binding var images: [UIImage]
CardDetailView将传递一个空数组给PhotoPicker,委托方法将用挑选的结果填充这个数组。
➤ 更新预览:
struct PhotoPicker_Previews: PreviewProvider {
static var previews: some View {
PhotoPicker(images: .constant([UIImage]()))
}
}
预览不会作用于图像数组,所以要传递一个常数组给绑定。
➤ 为PhotosCoordinator添加一个新的属性和初始化器:
var parent: PhotoPicker
init(parent: PhotoPicker) {
self.parent = parent
}
当你从PhotoPicker初始化PhotosCoordinator时,你会传递PhotoPicker实例,这样你就可以访问images数组。
➤ 将此方法添加到PhotoPicker中:
func makeCoordinator() -> PhotosCoordinator {
PhotosCoordinator(parent: self)
}
makeCoordinator()在Representable上下文中保存了协调者类。如果你需要在updateUIViewController(_:context:)中访问协调者类,你可以用context.coordinator来做。
UIViewControllerRepresentable在调用makeUIViewController(context:)之前调用makeCoordinator()。这个方法的存在仅仅是为了实例化协调者类,与UIKit类进行协调。如果你不需要从UIKit返回的数据,那么你就不需要创建一个协调器。
设置委托人¶
➤ 在方法末尾的makeUIViewController(context:)中,在返回picker之前添加这个:
picker.delegate = context.coordinator
picker现在知道PhotosCoordinator是它的委托人。PhotosCoordinator实现了delegate?.picker(_:didFinishPicking:),当用户点击系统照片选取模式中的添加按钮时。
NSItemProvider¶
你现在已经建立了SwiftUI PhotoPicker和UIKit PHPickerViewController之间的接口。剩下的就是要从模态结果中加载图片数组。
➤ 在 picker(_:didFinishPicking:)中,添加这段代码:
let itemProviders = results.map(\.itemProvider)
for item in itemProviders {
// load the image from the item here
}
每个PHPickerResult都持有一个NSItemProvider。使用关键路径.itemProvider,你可以从所有的结果中提取项目提供者到一个数组中,然后在该数组中进行迭代。
Swift
使用map和关键路径.itemProvider是let itemProviders = results.map { $0.itemProvider }的语法文件。
任何带有NS前缀的类都是一个继承自NSObject的Objective-C类。所以NSItemProvider最终继承于NSObject。当你想在你的应用程序中,或者在应用程序之间传输数据时,你会使用项目提供者。你可以询问项目提供者是否可以加载一个特定的类型,然后异步地加载它。在本章的后面,你将把图片从Safari拖到你的应用程序中 - 同样使用项目提供者。
➤ 在for循环内,添加代码以加载图像:
// 1
if item.canLoadObject(ofClass: UIImage.self) {
// 2
item.loadObject(ofClass: UIImage.self) { image, error in
// 3
if let error = error {
print("Error!", error.localizedDescription)
} else {
// 4
DispatchQueue.main.async {
if let image = image as? UIImage {
self.parent.images.append(image)
}
}
}
}
}
每个PHPickerResult都持有一个NSItemProvider。使用关键路径.itemProvider,你可以从所有的结果中提取项目提供者到一个数组中,然后在该数组中进行迭代。
Swift
使用map和关键路径.itemProvider是let itemProviders = results.map { $0.itemProvider }的语法文件。
任何带有NS前缀的类都是一个继承自NSObject的Objective-C类。所以NSItemProvider最终继承于NSObject。当你想在你的应用程序中,或者在应用程序之间传输数据时,你会使用项目提供者。你可以询问项目提供者是否可以加载一个特定的类型,然后异步地加载它。在本章的后面,你将把图片从Safari拖到你的应用程序中 - 同样使用项目提供者。
➤ 在for循环内,添加代码以加载图像:
@Environment(\.presentationMode) var presentationMode
➤ 在picker(_:didFinishPicking:)的结尾处,添加这个:
parent.presentationMode.wrappedValue.dismiss()
这将告诉环境关闭模态。PhotoPicker现在已经全部准备好在SwiftUI中使用。
➤ 实时预览PhotoPicker,看看系统的照片选择器如何工作。您可以选择多张照片,也可以从您的相册中选择。还可以显示所有选中的图片。

将PhotoPicker添加到你的应用程序中¶
要使用PhotoPicker,你需要把它与你的照片工具栏按钮连接起来,并把加载的照片保存为ImageElement。
➤ 打开CardDetailView.swift,给CardDetailView添加这个新属性:
@State private var images: [UIImage] = []
这是你要交给PhotoPicker的数组。
➤ 找到.sheet(item:)。在switch item中,添加新的模态:
case .photoPicker:
PhotoPicker(images: $images)
.onDisappear {
for image in images {
card.addElement(uiImage: image)
}
images = []
}
在这里,你显示模态,传入数组以保存用户将选择的照片。当模态消失时,你处理数组并将照片添加到卡片元素中。最后,清除图像数组,使其为下一次做好准备。
➤ 建立和运行,并选择第二个橙色卡片。使用系统的照片选择器添加几张照片。该应用程序将照片添加到卡片元素中,以便您可以调整它们的大小和位置。

Note
在写这篇文章的时候,模拟器的粉色花朵照片会导致错误。这似乎是苹果公司的一个错误,但它确实给了你一个机会,以确保你的PhotoPicker错误检查工作。在控制台中,你应该看到Error! 不能加载public.jpeg类型的表示。
将照片添加到模拟器中¶
如果你想要比苹果提供的更多的照片,你可以简单地把你的照片从Finder拖到模拟器上。模拟器会把这些照片放到照片库中,然后你就可以从PhotoPicker中访问它们。
从其他应用程序拖放¶
除了从你的设备上的照片添加外,你还可以从任何应用程序中添加拖放图片。与照片系统模式类似,你使用一个项目提供者来做这个。
首先设置模拟器,这样你就可以进行拖放了。
➤ 在iPad模拟器上建立和运行你的应用程序,并将iPad转为横向模式。你可以使用顶栏上的图标,或者使用Command-Right箭头。当你的鼠标光标刚刚接触到应用程序底部的黑色斜面时,慢慢向上拖动以显示基座。底座上的一个应用程序应该是Safari。
➤ 按住Safari图标,把它从基座上拖到卡片应用的右边。这时会出现一个空间,让你把这个图标放进去。

➤ 使用中间的栏来调整每个应用程序的大小,使其占到iPad屏幕的一半面积。
➤ 在卡片应用中,点击橙色卡片。在Safari浏览器中,谷歌你最喜欢的动物,然后点击图像。长按一张图片,直到它变得稍微大一点,然后把它拖到你的橙色卡片上。

卡片还没有准备好接收空投,所以什么都没有发生。如果弃置区能够接收物品,你会在图片旁边得到一个加号。
统一的类型标识符¶
你的应用程序需要区分投放图片和投放另一种格式,如文本。大多数应用程序都有相关的数据格式。例如,当你右击一个macOS文件并选择"随同打开"时,菜单上会显示与该文件的数据格式相关的所有应用程序。当你右键单击一个.png文件时,你可能会看到这样的列表:

这些是能够打开.png文件的应用程序。
统一类型标识符,或称UTI,识别文件类型。例如,PNG是一个标准的UTI,其标识符为public.png。它是图像数据库类型public.image的一个子类型。
Note
有许多标准的系统UTI,你可以在https://apple.co/3xASdxD找到。
如果你有一个自定义的数据格式,你可以创建你自己的UTI并将其包含在Info.plist中。然而,对于这个应用程序,你只需要使用public.image来接收任何图像格式。
添加下降视图修改器¶
在Xcode中,打开CardDetailView.swift。在工具栏修改器上面的content添加一个新的修改器:
// 1
.onDrop(of: [.image], isTargeted: nil) {
// 2
itemProviders, _ in
// 3
return true
}
翻阅代码:
- 这是你指定你希望处理的文件类型的标识符的地方;在你的例子中是
.image。有几个onDrop...修改器。这个修饰符需要一个UTType数组和一个布尔绑定,以指示当前是否有一个拖放操作正在发生。 - 该闭包在一个
NSItemProvider数组中显示被丢弃的项目和丢弃位置。目前,你不会使用位置,所以你用_替换了这个参数。 - 返回
true向系统表明,拖放成功了。
➤ 构建和运行,并重复将一个图像拖到卡上。
这一次,尽管下降没有任何作用,但你得到了加号。

➤ 在onDrop(of:isTargeted:perform:)里面,在return true之前,添加这段代码:
for item in itemProviders {
if item.canLoadObject(ofClass: UIImage.self) {
item.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
DispatchQueue.main.async {
card.addElement(uiImage: image)
}
}
}
}
}
这段代码与你之前为照片选取器写的代码几乎完全一样。遍历项目并将它们作为UIImage加载。在这里,你直接将图片添加到卡片的元素中。
➤ 建立并运行。重复将图像拖到橙色卡片上,这次的下拉动作将图像保存到卡片元素中。

在模拟器中,要在Safari中同时选择多张图片,拿起一张图片并开始拖动它。这一小段拖动很重要--没有它你就无法进行多重选择。然后按住Control。松开点击,再松开Control。图像上出现一个灰点,代表你的手指在设备上。点击其他图像,将它们添加到拖动堆中。当你收集完所有的图片后,把它们拖到卡片上。
目前,无论你把图像放在哪里,卡片都会在中心位置添加新元素。你可以使用拖放位置来放置你所拖放的元素。然而,为了计算元素的transform的offset,你需要把卡片上的位置点转换成从卡片中心的偏移量。这需要知道卡片的屏幕尺寸。你将在第20章"愉悦的用户体验--布局"中重新审视这个问题。
重构代码¶
CardDetailView现在变得相当庞大和复杂,你应该开始考虑如何重构它,并尽可能多地拆分出代码。一个更简洁的写法是使用一个替代的修改器,调用一个新的结构作为委托。
➤ 创建一个名为CardDrop.swift的新Swift文件以包含此委托。
➤ 将代码替换为以下内容:
import SwiftUI
struct CardDrop: DropDelegate {
@Binding var card: Card
}
你创建了一个新的结构,它将符合DropDelegate,并接收下降委托应更新的卡片。你需要实现一个必要的方法以符合DropDelegate。
➤ 将此方法添加到 CardDrop中:
func performDrop(info: DropInfo) -> Bool {
let itemProviders = info.itemProviders(for: [.image])
for item in itemProviders {
if item.canLoadObject(ofClass: UIImage.self) {
item.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
DispatchQueue.main.async {
card.addElement(uiImage: image)
}
}
}
}
}
return true
}
在这个方法的开始,你从下降信息中提取项目提供者,然后剩下的代码和你在CardDetailView中的一样。
DropDelegate还有其他几个必要的方法,它们有默认的实现,所以你不需要在你的应用程序中定义它们。
dropEntered(info:):一个潜在的丢弃物已经进入视图。dropExited(info:):一个潜在的丢弃物退出了视图。dropUpdated(info:):一个潜在的液滴在视图中移动。
如果你需要完全控制用户在屏幕上拖动项目的位置,那么就实现这些方法。
➤ 打开CardDetailView.swift。将onDrop(of:isTargeted:perform:)和它的所有代码替换为:
.onDrop(of: [.image], delegate: CardDrop(card: $card))
这里你仍然使用图像UTI,但你把代码卸载到了CardDrop。
通过这一行代码,你减少了表面的复杂性。CardDrop的代码很难阅读,而且你不需要在每次更新卡片细节代码时都要查看它。只要有可能,减少大脑过载是个好主意。 :]
➤ 构建并运行,你的应用程序和以前一样工作。

挑战¶
现在你知道了如何在SwiftUI中托管UIKit视图,你可以使用大量的苹果框架。PencilKit是一个有趣的框架,你可以在画布上绘画。
你的挑战是写几行代码并运行一个实时预览,你可以在其中涂鸦。
- 创建一个新的
View并导入PencilKit。创建一个PKCanvasView状态属性。把这个属性传递给一个UIViewRepresentable对象。 - 在
UIViewRepresentable对象中创建两个必要的方法。 - 这里只需要两行额外的代码。在
makeUIView(context:)中,将画布drawingPolicy设置为anyInput以允许手指和铅笔的输入,并返回画布。

你不会在当前版本的Cards中整合这个视图,但这可能是以后版本的一个功能,你可以从涂鸦中提取图像。
如果你有任何困难,你会在本章的挑战文件夹中找到这个挑战的解决方案,文件PencilView.swift。
关键点¶
SwiftUI和UIKit可以并驾齐驱。尽可能地使用SwiftUI,当你想要一个美味的UIKit框架时,就用Representable协议来使用它。如果你有一个UIKit应用,你也可以用UIHostingController来承载SwiftUI视图。- 委托模式在整个
UIKit中很常见。类持有一个协议类型的delegate属性,你将一个符合该协议的新对象分配给它。UIKit对象对其委托对象执行方法。 PHPickerViewController是一种从照片库中选择照片和视频的简单方法。访问照片一般需要权限,你必须在你的Info.plist中设置用法。然而,PHPickerViewController通过在一个单独的进程中运行来确保隐私,你的应用程序只能访问用户选择的媒体。- 项目提供者使应用程序之间更容易传递数据。
- 使用统一类型标识符和
onDrop修改器,你可以在你的应用程序中支持拖放。