3、UIViewRepresentable协议:将UIKit视图包装为SwiftUI组件
说实话,SwiftUI虽然好用,但总有些场景它搞不定。比如地图、视频播放、或者一些复杂的自定义控件。这时候怎么办?
苹果早就想到了——UIViewRepresentable协议就是专门干这个的。它就像一座桥,把UIKit的世界和SwiftUI的世界连起来。
3.1 理解UIViewRepresentable的本质
这个协议说白了就两个核心方法:
- makeUIView(context:) — 创建你的UIKit视图
- updateUIView(_:context:) — 当SwiftUI状态变化时更新它
我刚开始接触时觉得挺绕的。后来想明白了:makeUIView就像装修时打地基,updateUIView就像住进去后调整家具位置。一个管创建,一个管更新。
核心要点:UIViewRepresentable不是视图本身,它是一个包装器。SwiftUI通过它来托管UIKit视图的生命周期。
3.2 实现一个最简单的包装
咱们直接上手。假设我想在SwiftUI里用UILabel——虽然SwiftUI有Text,但UILabel有些特性是Text没有的。
struct UIKitLabel: UIViewRepresentable {
var text: String
var textColor: UIColor = .black
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.textAlignment = .center
label.font = UIFont.systemFont(ofSize: 16)
// 注意:这里只做初始配置
return label
}
func updateUIView(_ uiView: UILabel, context: Context) {
// 每次SwiftUI状态变化,这里都会被调用
uiView.text = text
uiView.textColor = textColor
}
}
嗯,这里要注意:makeUIView只调用一次,而updateUIView会调用多次。我在项目中遇到过有人把全部配置都塞进makeUIView里,结果状态变了视图不更新——这就是没理解两者的分工。
3.3 使用Coordinator处理事件
SwiftUI是数据驱动的,但UIKit很多控件靠代理和target-action。怎么把UIKit的事件传回SwiftUI?
答案是用Coordinator。它是UIViewRepresentable内部的一个协调器,专门处理UIKit的回调。
struct UIKitTextField: UIViewRepresentable {
@Binding var text: String
var placeholder: String
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.placeholder = placeholder
textField.borderStyle = .roundedRect
// 设置代理
textField.delegate = context.coordinator
// 添加事件监听
textField.addTarget(
context.coordinator,
action: #selector(Coordinator.textChanged(_:)),
for: .editingChanged
)
return textField
}
func updateUIView(_ uiView: UITextField, context: Context) {
// 只在值不同时更新,避免死循环
if uiView.text != text {
uiView.text = text
}
}
class Coordinator: NSObject, UITextFieldDelegate {
var parent: UIKitTextField
init(_ parent: UIKitTextField) {
self.parent = parent
}
@objc func textChanged(_ sender: UITextField) {
parent.text = sender.text ?? ""
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
}
我曾经踩过一个坑:在updateUIView里直接设置uiView.text = text,结果用户输入时触发了editingChanged事件,事件又更新了$text,然后SwiftUI重新渲染又调updateUIView…死循环了。所以加了个判断:if uiView.text != text。
小技巧:Coordinator里持有parent引用时,注意用weak避免循环引用。不过UIViewRepresentable的Coordinator生命周期和视图绑定,一般不会出问题。
3.4 性能优化:减少不必要的更新
SwiftUI的更新机制很频繁。一个父视图状态变了,所有子视图都可能重新走updateUIView。如果你的UIKit视图很重(比如地图、视频播放器),这会造成卡顿。
我的做法是:
- 实现equatable:让SwiftUI知道什么时候才需要更新
- 在updateUIView里做diff:只更新真正变化的部分
- 使用sizeThatFits:告诉SwiftUI你的视图需要多大空间
struct HeavyUIKitView: UIViewRepresentable {
var configuration: ViewConfiguration
// 告诉SwiftUI:只有configuration变了才需要更新
static func dismantleUIView(_ uiView: SomeHeavyView, coordinator: ()) {
// 清理资源,比如停止动画、移除通知
uiView.cleanup()
}
func updateUIView(_ uiView: SomeHeavyView, context: Context) {
// 只更新变化的部分
if uiView.currentConfig != configuration {
uiView.apply(configuration)
}
}
}
注意:别忘了实现dismantleUIView方法。当SwiftUI视图被移除时,这个方法会被调用。我见过有人没在这里释放资源,导致内存泄漏——尤其是那些注册了NotificationCenter观察者的视图。
3.5 实战:包装一个UIScrollView
SwiftUI的ScrollView在某些场景下不够灵活。比如我想实现一个水平滚动的日历控件,用UIScrollView更顺手。
struct HorizontalScrollView<Content: View>: UIViewRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.showsHorizontalScrollIndicator = false
scrollView.isPagingEnabled = true
return scrollView
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
// 使用UIHostingController把SwiftUI视图嵌入UIKit
let hostingController = UIHostingController(rootView: content)
hostingController.view.frame = scrollView.bounds
scrollView.addSubview(hostingController.view)
}
}
你想想看,这样我们就能在UIKit里用SwiftUI的声明式语法,同时享受UIScrollView的精细控制。说白了就是两头的好处都占了。
3.6 避坑指南
我总结了几条血泪教训:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 视图不更新 | updateUIView没被调用 | 检查属性是否被SwiftUI观察(用@State/@Binding) |
| 死循环 | updateUIView里修改了触发更新的属性 | 加条件判断,只在值不同时更新 |
| 内存泄漏 | Coordinator持有parent强引用 | 用weak引用,或在dismantleUIView里清理 |
| 布局异常 | UIKit视图的intrinsicContentSize不对 | 实现intrinsicContentSize或使用frame |
我曾经有一个项目,因为没处理好updateUIView里的条件判断,导致用户每次输入都触发整个视图重建——卡得不行。后来加了个简单的diff逻辑,性能直接翻倍。
总结一下:UIViewRepresentable的核心就是三个方法——makeUIView创建、updateUIView更新、Coordinator桥接事件。记住它们的职责分工,你就能在SwiftUI里随心所欲地使用任何UIKit控件。
下一章我们会聊UIViewControllerRepresentable,它和UIViewRepresentable类似,但用来包装整个视图控制器。到时候你会看到更多有意思的实战案例。