自2019年首次推出以来,SwiftUI与UIKit的互操作性确实很强。UIView 和UIViewController 实例都可以被包装成与 SwiftUI 完全兼容,而且UIHostingController 可以让我们在基于 UIKit 的视图控制器中渲染任何 SwiftUI 视图。
然而,尽管macOS从一开始就有一个NSHostingView ,可以在任何NSView (不需要视图控制器)中直接内联SwiftUI视图,但在iOS上从来没有一个内置的方法来做同样的事情。当然,我们总是可以从一个UIHostingController 实例中抓取底层的view ,或者将我们的托管控制器添加到父级视图控制器中,以便能够在基于UIView 的层次结构中更深入地使用其视图--但这些解决方案都没有完全顺利。
这把我们带到了iOS 16(在写这篇文章时,它目前还在测试阶段)。虽然苹果没有在这个版本中引入一个完全通用的UIHostingView ,但他们已经解决了缺乏这种视图给我们带来的最常见的挑战之一--即如何在UITableViewCell 或UICollectionViewCell 中渲染一个SwiftUI视图。
在我们开始之前,只需简单说明一下。本文的所有代码样本将完全基于UITableView ,但完全相同的技术也可以用于UICollectionView 。
向UIHostingConfiguration问好
在iOS 14中,苹果引入了一种新的方式来配置作为UITableView 或UICollectionView 的一部分被渲染的单元格,这让我们可以使用所谓的内容配置来解耦我们正在渲染的内容和我们单元格包含的实际子视图。这个API现在已经扩展了一个新的UIHostingConfiguration ,它让我们可以使用任何SwiftUI视图层次定义单元格的内容。
因此,无论我们在哪里配置我们的单元格--例如使用老式的UITableViewDataSource 去排队方法--我们现在可以选择将这样的托管配置分配给特定单元格的contentConfiguration 属性,UIKit 将自动渲染我们在该单元格内提供的 SwiftUI 视图。
class ListViewController: UIViewController, UITableViewDataSource {
private var items: [Item]
private let databaseController: DatabaseController
private let cellReuseID = "cell"
private lazy var tableView = UITableView()
...
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: cellReuseID,
for: indexPath
)
let item = items[indexPath.row]
cell.contentConfiguration = UIHostingConfiguration {
HStack {
Text(item.title)
Spacer()
Button(action: { [weak self] in
if let indexPath = self?.tableView.indexPath(for: cell) {
self?.toggleFavoriteStatusForItem(atIndexPath: indexPath)
}
}, label: {
Image(systemName: "star" + (item.isFavorite ? ".fill" : ""))
.foregroundColor(item.isFavorite ? .yellow : .gray)
})
}
}
return cell
}
private func toggleFavoriteStatusForItem(atIndexPath indexPath: IndexPath) {
items[indexPath.row].isFavorite.toggle()
databaseController.update(items[indexPath.row])
}
}
很好!正如上面的代码示例所示,我们可以很容易地使用闭包将事件从我们单元格的SwiftUI视图送回我们的视图控制器,但在这样做的时候,有几件事是需要注意的:
首先,你可能已经注意到,我们在将单元格的当前索引路径传递给我们的toggleFavoriteStatusForItem 方法之前,向表格视图询问了它。这是因为,如果我们的表视图支持移动和删除等操作,一旦我们最喜欢的按钮被点击,我们的单元格可能会在不同的索引路径上--所以如果我们要捕获并使用传入我们的单元格配置方法的indexPath 参数,那么我们可能会意外地最终更新错误的模型。
第二,我们必须记住,我们仍然停留在UIKit的世界里,这意味着我们必须考虑到内存管理(至少,比在SwiftUI的奇妙的基于值类型的世界里操作时更多)。因此,上面的[weak self] 闭包捕获,以避免我们的视图控制器和它的UITableView 之间的保留循环。
最后,还有状态管理,就像在使用UIHostingController 或UIViewRepresentable 等工具弥合 UIKit 和 SwiftUI 之间的差距时一样,我们可能需要写一点自定义代码来确保我们单元格的 SwiftUI 视图层次呈现我们视图控制器数据的最新版本。
管理状态更新
例如,当用户点击我们单元格的喜爱按钮时,我们的单元格目前没有更新以反映其Item 模型的新状态。这是因为我们实际上没有使用 SwiftUI 的声明性状态管理系统来驱动这些更新,所以我们嵌套的 SwiftUI 视图层次结构没有办法知道它的数据模型被修改了。
解决这个问题的一个方法是使用UIKit来对这些变化做出反应,而不是使用SwiftUI的视图更新机制。例如,我们可以在一个项目的收藏状态发生变化时调用我们的表视图的reloadRows 方法--像这样:
class ListViewController: UIViewController, UITableViewDataSource {
...
private func toggleFavoriteStatusForItem(atIndexPath indexPath: IndexPath) {
items[indexPath.row].isFavorite.toggle()
databaseController.update(items[indexPath.row])
tableView.reloadRows(at: [indexPath], with: .none)
}
}
这很好,因为它将使表视图调用我们的数据源单元格配置方法,这将给我们一个机会来更新变化的项目单元格和嵌入其中的SwiftUI视图。
但是,假设我们想使用SwiftUI的状态管理系统来跟踪我们单元格的状态何时被修改。要做到这一点的一个方法是引入某种形式的ObservableObject ,然后我们的视图控制器可以将其注入每个单元的SwiftUI视图层次中。
在这种情况下,引入这种类型的另一个好处是,它也会给我们一个整洁的地方来封装所有与我们的Item 模型相关的视图逻辑,比如用来计算我们最喜欢的按钮的标题和前景颜色的代码。让我们继续介绍这样一个类,我们将其称为ItemViewModel :
class ItemViewModel: ObservableObject {
@Published private(set) var item: Item
private let onItemChange: (Item) -> Void
init(item: Item, onItemChange: @escaping (Item) -> Void) {
self.item = item
self.onItemChange = onItemChange
}
func toggleFavoriteStatus() {
item.isFavorite.toggle()
onItemChange(item)
}
}
extension ItemViewModel {
var favoriteButtonIconName: String {
"star" + (item.isFavorite ? ".fill" : "")
}
var favoriteButtonColor: Color {
item.isFavorite ? .yellow : .gray
}
}
请注意,我们正在启用一个onItemChange 闭包来传递给我们的视图模型,这将让我们的视图控制器观察到每当视图模型的底层Item 模型的副本被修改时。我们当然可以选择在这里使用Combine,让我们的视图控制器钩住与视图模型的item 属性相连的发布器,但在这种情况下,一个简单的闭包就能完成工作。
接下来,让我们把我们的单元格嵌入的SwiftUI代码提取到一个专门的View ,这将让我们的视图层次观察我们的新视图模型作为一个ObservedObject :
struct ItemView: View {
@ObservedObject var viewModel: ItemViewModel
var body: some View {
HStack {
Text(viewModel.item.title)
Spacer()
Button(action: viewModel.toggleFavoriteStatus) {
Image(systemName: viewModel.favoriteButtonIconName)
.foregroundColor(viewModel.favoriteButtonColor)
}
}
}
}
最后,剩下的就是更新我们的视图控制器,以使用我们新引入的类型,这也将让我们删除我们之前用来管理每个项目的收藏状态的toggleFavoriteStatusForItem 方法:
class ListViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: cellReuseID,
for: indexPath
)
let viewModel = ItemViewModel(
item: items[indexPath.row],
onItemChange: { [weak self] item in
guard let indexPath = self?.tableView.indexPath(for: cell) else {
return
}
self?.items[indexPath.row] = item
self?.databaseController.update(item)
}
)
cell.contentConfiguration = UIHostingConfiguration {
ItemView(viewModel: viewModel)
}
return cell
}
}
非常好当然,上面的实现只处理由我们的视图控制器及其嵌套的SwiftUI视图执行的内部数据模型变化。如果这些模型也可以在其他地方被修改,那么每当这种情况发生时,我们就必须通知我们的视图控制器,并相应地重新加载我们表视图的数据。然而,我们使用的是SwiftUI和新的UIHostingConfiguration API,这并不影响我们这样做,因为任何这样的外部更新都会导致我们重新配置我们的单元。
桥接的刷卡动作
新的UIHostingConfiguration API的另一个整洁的方面,以及它在UIKit和SwiftUI之间架起桥梁的方式是,它将自动把我们的视图层次结构包含的任何SwiftUIswipeActions 转换为基于UIKit的刷卡动作。
因此,举例来说,如果我们想给我们的每个项目单元添加一个删除的刷卡动作,那么我们就可以通过在我们的UIHostingConfiguration 闭包中放置一个修改器来实现--像这样:
cell.contentConfiguration = UIHostingConfiguration {
ItemView(viewModel: viewModel).swipeActions {
Button(role: .destructive, action: { [weak self] in
guard let indexPath = self?.tableView.indexPath(for: cell) else {
return
}
self?.items.remove(at: indexPath.row)
self?.databaseController.delete(viewModel.item)
self?.tableView.deleteRows(at: [indexPath], with: .automatic)
}, label: {
Label("Delete", systemImage: "trash")
})
}
}
然而,虽然上述情况非常整洁,但需要指出的是,(在撰写本文时)并不是SwiftUI的每一个方面都能自动地以这种方式衔接。例如,如果我们将一个NavigationLink 放在我们的UIHostingConfiguration ,那么它就不会自动连接到我们的视图控制器所嵌入的任何UINavigationController (在使用UIHostingController 时确实会发生这种情况)。希望这一点能在以后的时间里得到解决。
总结
SwiftUI和UIKit之间的互操作性越来越强大,这反过来又使我们能够继续在我们构建的应用程序中混合这两个UI框架。UIHostingConfiguration 的引入尤其值得欢迎,因为UITableView 和UICollectionView (它以完全相同的方式支持这个新的托管配置 API)仍然提供了比它们的 SwiftUI 对应物更多的功能 - 所以能够在它们中内联 SwiftUI 视图绝对应该被证明是非常有用的。
谢谢你的阅读!