第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
修改器,你可以在你的应用程序中支持拖放。