跳转至

第17章:与UIKit的接口

有时你会需要一个SwiftUI中没有的用户界面功能。UIKit是一个开发用户界面的框架,从2008年的第一部iPhone开始就已经存在了。作为一个成熟的框架,它的复杂性和范围在这些年里不断增长,SwiftUI需要一些时间来追赶。有了UIKit,除其他外,你可以处理铅笔互动,支持指针行为,做更复杂的触摸和手势识别,以及任何数量的图像和颜色操作。

有了UIKit作为SwiftUI的备份,你可以实现任何你能想到的界面设计。你已经使用UIImage将图像加载到SwiftUI的Image视图中,使用UIViewRepresentable协议使用任何UIKit视图来代替SwiftUI视图几乎同样简单。

本章将介绍从你的应用程序外部加载图像和照片。首先,你将从你的照片库中加载照片,然后你将从其他应用程序(如Safari)拖动或复制图片。

UIKit

UIKit有一个与SwiftUI完全不同的数据流范式。在SwiftUI中,你定义了View和一个真实源。当真理之源发生变化时,视图会自动更新。UIView没有绑定数据的概念,所以当数据发生变化时,你必须明确地更新视图。

UIKit vs SwiftUI

只要你能为新的应用程序,你应该坚持使用SwiftUI。然而,UIKit确实有一些有用的框架,如果不使用其中一个框架,往往就无法完成一些事情。PhotoKit提供了对Photos应用中的照片和视频的访问,而PhotosUI框架提供了一个用于资产选择的用户界面。

使用Representable协议

Representable协议是你在SwiftUI应用程序中插入UIKit视图的方式。你不是为SwiftUI视图创建一个符合View的结构,而是创建一个符合UIViewRepresentable的结构--用于单个视图--或UIViewControllerRepresentable,如果你想使用视图控制器对视图进行复杂管理。为了接收来自UIKit视图的信息,你创建一个协调员。

UIViewRepresentable and Coordinator

为了开始使用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()

➤ 预览视图。

Compare previews

SwiftUI的文本视图只占用了足够的空间来显示自己,而UIKit的视图将占用整个可用空间。

UIKit委托模式

许多UIKit类使用协议,要求一个委托对象来处理事件。例如,在创建UITableView时,你指定了一个实现UITableViewDelegate的类,并管理当用户选择表格中的一行时应用程序应该做什么。

Delegate pattern

使用委托,你可以改变表格的行为而不需要子类化UITableView

PhotoPicker中,你的代码将使用系统的照片挑选器:PHPickerViewController。这个类在UIView中显示用户的照片,并允许用户从库中选择照片。当用户点击添加或取消时,会发生一个事件。当这种情况发生时,PHPickerViewController将通知委托对象,而委托对象可以采取任何行动。这个委托对象必须是NSObject的子类,并且符合PHPickerViewControllerDelegate

PhotoPicker是一个结构,所以它不能符合PHPickerViewControllerDelegate。在PhotoPicker中,你将创建一个协调器类,它将与系统的照片挑选器对接,并通过PhotoPicker将所有选择的图片返回给CardDetailView

Representable PhotoPicker with delegation

采摘照片

由于系统的照片挑选器是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

翻阅代码:

  1. 你创建一个PHPickerConfiguration。这个配置有一个过滤器,你可以指定照片的类型供用户选择。除了images,你可以选择livePhotosvideos
  2. 指定允许用户选择的照片数量。使用0来允许选择多张照片。
  3. 使用之前创建的configuration创建一个PHPickerViewController

➤ 在PhotoPicker中创建一个新的类。

class PhotosCoordinator: NSObject,
  PHPickerViewControllerDelegate {
}

这个类不需要是PhotoPicker的内部,但是你可能不想单独使用它,让它成为内部意味着它只能在PhotoPicker的范围内。

PhotosCoordinatorNSObject的一个子类。这是大多数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 PhotoPickerUIKit PHPickerViewController之间的接口。剩下的就是要从模态结果中加载图片数组。

➤ 在 picker(_:didFinishPicking:)中,添加这段代码:

let itemProviders = results.map(\.itemProvider)
for item in itemProviders {
  // load the image from the item here 
}

每个PHPickerResult都持有一个NSItemProvider。使用关键路径.itemProvider,你可以从所有的结果中提取项目提供者到一个数组中,然后在该数组中进行迭代。

Swift

使用map和关键路径.itemProviderlet itemProviders = results.map { $0.itemProvider }的语法文件。

任何带有NS前缀的类都是一个继承自NSObjectObjective-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和关键路径.itemProviderlet itemProviders = results.map { $0.itemProvider }的语法文件。

任何带有NS前缀的类都是一个继承自NSObjectObjective-C类。所以NSItemProvider最终继承于NSObject。当你想在你的应用程序中,或者在应用程序之间传输数据时,你会使用项目提供者。你可以询问项目提供者是否可以加载一个特定的类型,然后异步地加载它。在本章的后面,你将把图片从Safari拖到你的应用程序中 - 同样使用项目提供者。

➤ 在for循环内,添加代码以加载图像:

@Environment(\.presentationMode) var presentationMode

➤ 在picker(_:didFinishPicking:)的结尾处,添加这个:

parent.presentationMode.wrappedValue.dismiss()

这将告诉环境关闭模态。PhotoPicker现在已经全部准备好在SwiftUI中使用。

➤ 实时预览PhotoPicker,看看系统的照片选择器如何工作。您可以选择多张照片,也可以从您的相册中选择。还可以显示所有选中的图片。

System photo picker

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 = []
  }

在这里,你显示模态,传入数组以保存用户将选择的照片。当模态消失时,你处理数组并将照片添加到卡片元素中。最后,清除图像数组,使其为下一次做好准备。

➤ 建立和运行,并选择第二个橙色卡片。使用系统的照片选择器添加几张照片。该应用程序将照片添加到卡片元素中,以便您可以调整它们的大小和位置。

Added photos

Note

在写这篇文章的时候,模拟器的粉色花朵照片会导致错误。这似乎是苹果公司的一个错误,但它确实给了你一个机会,以确保你的PhotoPicker错误检查工作。在控制台中,你应该看到Error! 不能加载public.jpeg类型的表示

将照片添加到模拟器中

如果你想要比苹果提供的更多的照片,你可以简单地把你的照片从Finder拖到模拟器上。模拟器会把这些照片放到照片库中,然后你就可以从PhotoPicker中访问它们。

从其他应用程序拖放

除了从你的设备上的照片添加外,你还可以从任何应用程序中添加拖放图片。与照片系统模式类似,你使用一个项目提供者来做这个。

首先设置模拟器,这样你就可以进行拖放了。

➤ 在iPad模拟器上建立和运行你的应用程序,并将iPad转为横向模式。你可以使用顶栏上的图标,或者使用Command-Right箭头。当你的鼠标光标刚刚接触到应用程序底部的黑色斜面时,慢慢向上拖动以显示基座。底座上的一个应用程序应该是Safari

➤ 按住Safari图标,把它从基座上拖到卡片应用的右边。这时会出现一个空间,让你把这个图标放进去。

Drop Safari

➤ 使用中间的栏来调整每个应用程序的大小,使其占到iPad屏幕的一半面积。

➤ 在卡片应用中,点击橙色卡片。在Safari浏览器中,谷歌你最喜欢的动物,然后点击图像。长按一张图片,直到它变得稍微大一点,然后把它拖到你的橙色卡片上。

Drag a giraffe

卡片还没有准备好接收空投,所以什么都没有发生。如果弃置区能够接收物品,你会在图片旁边得到一个加号。

统一的类型标识符

你的应用程序需要区分投放图片和投放另一种格式,如文本。大多数应用程序都有相关的数据格式。例如,当你右击一个macOS文件并选择"随同打开"时,菜单上会显示与该文件的数据格式相关的所有应用程序。当你右键单击一个.png文件时,你可能会看到这样的列表:

.png app list

这些是能够打开.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
}

翻阅代码:

  1. 这是你指定你希望处理的文件类型的标识符的地方;在你的例子中是.image。有几个onDrop...修改器。这个修饰符需要一个UTType数组和一个布尔绑定,以指示当前是否有一个拖放操作正在发生。
  2. 该闭包在一个NSItemProvider数组中显示被丢弃的项目和丢弃位置。目前,你不会使用位置,所以你用_替换了这个参数。
  3. 返回true向系统表明,拖放成功了。

➤ 构建和运行,并重复将一个图像拖到卡上。

这一次,尽管下降没有任何作用,但你得到了加号。

Drop is active

➤ 在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加载。在这里,你直接将图片添加到卡片的元素中。

➤ 建立并运行。重复将图像拖到橙色卡片上,这次的下拉动作将图像保存到卡片元素中。

A tower of giraffes

在模拟器中,要在Safari中同时选择多张图片,拿起一张图片并开始拖动它。这一小段拖动很重要--没有它你就无法进行多重选择。然后按住Control。松开点击,再松开Control。图像上出现一个灰点,代表你的手指在设备上。点击其他图像,将它们添加到拖动堆中。当你收集完所有的图片后,把它们拖到卡片上。

目前,无论你把图像放在哪里,卡片都会在中心位置添加新元素。你可以使用拖放位置来放置你所拖放的元素。然而,为了计算元素的transformoffset,你需要把卡片上的位置点转换成从卡片中心的偏移量。这需要知道卡片的屏幕尺寸。你将在第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的代码很难阅读,而且你不需要在每次更新卡片细节代码时都要查看它。只要有可能,减少大脑过载是个好主意。 :]

➤ 构建并运行,你的应用程序和以前一样工作。

Final drag and drop

挑战

现在你知道了如何在SwiftUI中托管UIKit视图,你可以使用大量的苹果框架。PencilKit是一个有趣的框架,你可以在画布上绘画。

你的挑战是写几行代码并运行一个实时预览,你可以在其中涂鸦。

  • 创建一个新的View并导入PencilKit。创建一个PKCanvasView状态属性。把这个属性传递给一个UIViewRepresentable对象。
  • UIViewRepresentable对象中创建两个必要的方法。
  • 这里只需要两行额外的代码。在makeUIView(context:)中,将画布drawingPolicy设置为anyInput以允许手指和铅笔的输入,并返回画布。

A scribble using PencilKit

你不会在当前版本的Cards中整合这个视图,但这可能是以后版本的一个功能,你可以从涂鸦中提取图像。

如果你有任何困难,你会在本章的挑战文件夹中找到这个挑战的解决方案,文件PencilView.swift

关键点

  • SwiftUIUIKit可以并驾齐驱。尽可能地使用SwiftUI,当你想要一个美味的UIKit框架时,就用Representable协议来使用它。如果你有一个UIKit应用,你也可以用UIHostingController来承载SwiftUI视图。
  • 委托模式在整个UIKit中很常见。类持有一个协议类型的delegate属性,你将一个符合该协议的新对象分配给它。UIKit对象对其委托对象执行方法。
  • PHPickerViewController是一种从照片库中选择照片和视频的简单方法。访问照片一般需要权限,你必须在你的Info.plist中设置用法。然而,PHPickerViewController通过在一个单独的进程中运行来确保隐私,你的应用程序只能访问用户选择的媒体。
  • 项目提供者使应用程序之间更容易传递数据。
  • 使用统一类型标识符和onDrop修改器,你可以在你的应用程序中支持拖放。