关于 swiftui-uikit-interop
面向 iOS 开发者的 SwiftUI 与 UIKit 互操作指南,涵盖双向封装、生命周期、布局和状态同步,并说明常见陷阱及迁移模式。
主要功能
- 01将 UIView 和控制器封装到 SwiftUI
- 02在 UIKit 中嵌入 SwiftUI
- 03通过 Coordinator 传递委托事件
- 04同步绑定、环境值与视图状态
- 05处理布局尺寸和生命周期清理
适用场景
- 01在 SwiftUI 中使用 UIKit 系统选择器
- 02把 SwiftUI 页面加入现有 UIKit 屏幕
- 03同步文本视图等控件的双向状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill swiftui-uikit-interop安装后在 Claude Code 中输入/swiftui-uikit-interop确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/evals.jsonreferences/hosting-migration.mdrepresentable-recipes.md详细介绍
SwiftUI-UIKit 互操作
双向桥接 UIKit 和 SwiftUI:封装 UIKit 视图和控制器、将 SwiftUI 嵌入 UIKit 屏幕,并在不重复管理生命周期的情况下同步状态。
完整的封装示例请参阅 references/representable-recipes.md,UIKit 到 SwiftUI 的迁移模式请参阅 references/hosting-migration.md。
目录
- UIViewRepresentable 协议
- UIViewControllerRepresentable 协议
- Coordinator 模式
- UIHostingController
- 尺寸与布局
- 状态同步模式
- UIKit 自动观察跟踪
- Sendable 注意事项
- 常见错误
- 检查清单
- 参考资料
UIViewRepresentable 协议
使用 UIViewRepresentable 封装任何 UIView 子类,以便在 SwiftUI 中使用。
必需方法
struct WrappedTextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
// Called ONCE when SwiftUI inserts this view into the hierarchy.
// Create and return the UIKit view. One-time setup goes here.
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
// Called on EVERY SwiftUI state change that affects this view.
// Synchronize SwiftUI state into the UIKit view.
// Guard against redundant updates to avoid loops.
if uiView.text != text {
uiView.text = text
}
}
}
生命周期时机
| 方法 | 调用时机 | 用途 |
|---|---|---|
makeCoordinator() | 在 makeUIView 之前。每个 representable 生命周期中调用一次。 | 创建委托/数据源引用类型。 |
makeUIView(context:) | representable 进入视图树时调用一次。 | 分配并配置 UIKit 视图。 |
updateUIView(_:context:) | 紧接着 makeUIView 调用,之后在每次相关状态变化时调用。 | 将 SwiftUI 状态推送到 UIKit 视图。 |
dismantleUIView(_:coordinator:) | representable 从视图树中移除时调用。 | 清理观察者、计时器和订阅。 |
sizeThatFits(_:uiView:context:) | 布局期间,SwiftUI 需要视图的理想尺寸时调用。iOS 16+。 | 返回自定义尺寸建议。 |
为什么 updateUIView 是最重要的方法: representable 读取的任何 @Binding、@State、@Environment 或 @Observable 属性发生变化时,SwiftUI 都会调用它。所有从 SwiftUI 到 UIKit 的状态同步都在这里进行。如果遗漏某个属性,UIKit 视图的状态就会不同步。
可选:dismantleUIView
static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) {
// Remove observers, invalidate timers, cancel subscriptions.
// The coordinator is passed in so you can access state stored on it.
coordinator.cancellables.removeAll()
}
可选:sizeThatFits(iOS 16+)
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context
) -> CGSize? {
// Return nil to fall back to UIKit's intrinsicContentSize.
// Return a CGSize to override SwiftUI's sizing for this view.
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
UIViewControllerRepresentable 协议
使用 UIViewControllerRepresentable 封装 UIViewController 子类——通常用于系统选取器、文档扫描器、邮件撰写界面,或任何以模态方式呈现的控制器。
struct DocumentScannerView: UIViewControllerRepresentable {
@Binding var scannedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let scanner = VNDocumentCameraViewController()
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
// Usually empty for modal controllers -- nothing to push from SwiftUI.
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
}
处理已呈现控制器的结果
Coordinator 捕获委托回调,并通过父视图的 @Binding 或闭包将结果传回 SwiftUI:
extension DocumentScannerView {
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let parent: DocumentScannerView
init(_ parent: DocumentScannerView) { self.parent = parent }
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
parent.scannedImages = (0..<scan.pageCount).map { scan.imageOfPage(at: $0) }
parent.dismiss()
}
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
parent.dismiss()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
parent.dismiss()
}
}
}
Coordinator 模式
为什么需要 Coordinator
UIKit 的委托、数据源和目标-动作模式都要求使用引用类型(class)。SwiftUI representable 结构体是值类型,不能充当委托。Coordinator 是由 SwiftUI 创建和管理的 class 实例,它的生命周期与 representable 视图一致。
结构
始终将 Coordinator 嵌套在 representable 中或扩展中。保存对 parent(representable 结构体)的引用,以便 Coordinator 能够回写 @Binding 属性。
struct SearchBarView: UIViewRepresentable {
@Binding var text: String
var onSearch: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator // Set delegate HERE, not in updateUIView
return bar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
}
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearch(parent.text)
searchBar.resignFirstResponder()
}
}
}
关键规则
-
在
makeUIView/makeUIViewController中设置代理,绝不要在updateUIView中设置。 对所表示视图的状态变更可能导致更新方法运行多次;在其中设置代理会造成重复赋值,并可能触发意外副作用。 -
自行刷新复制的父级状态。 如果协调器通过
var parent存储可表示对象,请在updateUIView或updateUIViewController开始时赋值context.coordinator.parent = self。绑定仍指向其真实数据源,但闭包和非绑定值会被复制到协调器中。 -
在闭包中使用
[weak coordinator],以避免协调器与捕获它的 UIKit 对象之间形成强引用循环。
UIHostingController
使用 UIHostingController 将 SwiftUI 视图嵌入 UIKit 视图控制器中。
基本嵌入
final class ProfileViewController: UIViewController {
private let hostingController = UIHostingController(rootView: ProfileView())
override func viewDidLoad() {
super.viewDidLoad()
// 1. Add as child
addChild(hostingController)
// 2. Add and constrain the view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
// 3. Notify the child
hostingController.didMove(toParent: self)
}
}
这三个步骤(addChild、添加视图、didMove)缺一不可。跳过任何一步都会导致容器回调失效,进而破坏外观转换和特征传播。
尺寸选项(iOS 16+)
@available(iOS 16.0, *)
hostingController.sizingOptions = [.intrinsicContentSize]
| 选项 | 效果 |
|---|---|
.intrinsicContentSize | 托管控制器的视图会将其 SwiftUI 内容尺寸报告为 intrinsicContentSize。当托管视图应自行调整尺寸时,可在 Auto Layout 中使用。 |
.preferredContentSize | 将 preferredContentSize 更新为与 SwiftUI 内容一致。以弹出框或表单页形式呈现时使用。 |
更新根视图
UIKit 中的数据发生变化时,将新状态传入托管的 SwiftUI 视图:
func updateProfile(_ profile: Profile) {
hostingController.rootView = ProfileView(profile: profile)
}
对于可观察模型,传入 @Observable 对象后,SwiftUI 会自动跟踪变化,无需重新赋值 rootView。
UIHostingConfiguration(iOS 16+)
无需管理子级托管控制器,即可直接在 UICollectionViewCell 或 UITableViewCell 中渲染 SwiftUI 内容:
@available(iOS 16.0, *)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentConfiguration = UIHostingConfiguration {
ItemRow(item: items[indexPath.item])
}
return cell
}
尺寸与布局
intrinsicContentSize 桥接
由 UIViewRepresentable 包装的 UIKit 视图通过 intrinsicContentSize 向 SwiftUI 传达其自然尺寸。除非被 frame() 或 fixedSize() 覆盖,否则 SwiftUI 会在布局期间遵循该尺寸。
由 SwiftUI 管理的几何属性
SwiftUI 管理所表示视图的 center、bounds、frame 和 transform。不要在 makeUIView 或 updateUIView 中直接设置 uiView 的这些属性。对于内部子图层,请使用 sizeThatFits、固有内容尺寸、SwiftUI 布局修饰符,或自定义 UIKit 子视图中的布局代码。
fixedSize() 与 frame() 的交互
| SwiftUI 修饰符 | 对可表示对象的影响 |
|---|---|
| 无修饰符 | SwiftUI 将 intrinsicContentSize 作为理想尺寸;视图可灵活调整。 |
.fixedSize() | 强制可表示对象在两个轴上都采用其理想(固有)尺寸。 |
.fixedSize(horizontal: true, vertical: false) | 将宽度固定为固有尺寸;高度仍可灵活调整。 |
.frame(width:height:) | 覆盖建议尺寸;UIKit 视图会接收此尺寸。 |
使用 UIHostingController 的 Auto Layout
将 UIHostingController 作为子级嵌入时,请使用约束固定其视图。使用 .sizingOptions = [.intrinsicContentSize],以便 Auto Layout 能查询 SwiftUI 内容的自然尺寸,供自适应尺寸单元格或可变高度区段使用。
状态同步模式
@Binding:双向同步(SwiftUI <-> UIKit)
当两端都需要读取并写入同一个值时,使用 @Binding。协调器在代理回调中写入 parent.bindingProperty;updateUIView 读取绑定并将其传入 UIKit 视图。
// SwiftUI -> UIKit: in updateUIView
if uiView.text != text { uiView.text = text }
// UIKit -> SwiftUI: in Coordinator delegate method
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
闭包:单向事件(UIKit -> SwiftUI)
对于即发即弃事件(点击按钮、提交搜索、完成扫描),请传入闭包,而不是绑定:
struct WebViewWrapper: UIViewRepresentable {
let url: URL
var onNavigationFinished: ((URL) -> Void)?
}
环境值
通过 context.environment 在可表示对象的方法中访问 SwiftUI 环境值:
func updateUIView(_ uiView: UITextView, context: Context) {
let isEnabled = context.environment.isEnabled
uiView.isEditable = isEnabled
// Respond to color scheme changes
let colorScheme = context.environment.colorScheme
uiView.backgroundColor = colorScheme == .dark ? .systemGray6 : .white
}
避免更新循环
当 SwiftUI 有了表示视图的新状态时,会调用 updateUIView,其中包括协调器写入 @Binding 所触发的更改。请添加保护逻辑,避免冗余更新导致无限循环:
func updateUIView(_ uiView: UITextView, context: Context) {
// GUARD: Only update if values actually differ
if uiView.text != text {
uiView.text = text
}
}
如果没有保护逻辑,设置 uiView.text 可能会触发委托的 textViewDidChange,后者会写入 parent.text,进而再次触发 updateUIView。
UIKit 自动观察跟踪
对于与 SwiftUI 共用 @Observable 模型的 UIKit 屏幕,请保留 UIKit 屏幕,并在 UIKit 的跟踪更新钩子中读取被观察的状态:
- iOS 26 及更高版本:使用
updateProperties()更新标签、颜色、可见性、启用状态及其他非布局 UI;使用布局钩子处理几何信息;使用单元格配置更新处理器处理单元格。 - iOS 18:要启用 UIKit 自动观察跟踪,需在
Info.plist中设置UIObservationTrackingEnabled。 - iOS 17:虽然已有
@Observable,但 UIKit 自动观察跟踪尚不可用。手动使用withObservationTracking只能触发一次;不要围绕它构建轮询循环。 - iOS 15–16 或现有的
ObservableObject:使用 Combine 的objectWillChange、委托、通知或显式回调。
迁移模式请参阅 references/hosting-migration.md。
Sendable 注意事项
UIKit 委托协议不符合 Sendable。当协调器遵循 UIKit 委托协议时,它会继承 UIKit 的主执行器隔离。请将协调器标记为 @MainActor,或仅对确实不访问 UIKit 状态的方法使用 nonisolated。在 Swift 6 严格并发模式下:
@MainActor
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
// Delegate methods are main-actor-isolated -- safe to access UIKit and @Binding.
}
如果要跨隔离边界传递闭包,请确保它们为 @Sendable,或在正确的执行器上捕获。
常见错误
| 错误 | 修复方法 |
|---|---|
在 update* 中重新创建 UIKit 对象或委托 | 在 make* 中创建一次;仅更新已更改的状态。 |
| 直接修改表示视图的 frame、bounds 或 transform | 让 SwiftUI 调整其尺寸;使用内在尺寸、sizeThatFits、修饰符或内部布局。 |
| UIKit 闭包保留了协调器 | 弱引用捕获,并确保只有一个生命周期所有者。 |
| 选择器/控制器的退出路径遗漏了关闭或完成处理 | 将每个委托退出路径都交由协调器的同一清理路径处理。 |
| 观察器、计时器或订阅的生命周期超过视图 | 在 dismantle* 中移除/取消它们。 |
| 宿主控制器使用手动 frame,但未进行包含操作 | 使用 Auto Layout 和 addChild/didMove(toParent:)。 |
在协调器中使用 @State | 使用存储属性,并通过绑定/回调进行通信。 |
手动轮询 withObservationTracking | 在可用时使用 UIKit 自动观察钩子;对于较旧的目标版本,使用显式失效通知。 |
审查清单
- 在
make*(而非update*)中创建视图/控制器 - 在
make*(而非update*)中将协调器设为委托 - 使用
@Binding进行双向状态同步 -
updateUIView处理所有 SwiftUI 状态更改,并通过保护逻辑避免冗余更新 - 如有需要,由
dismantleUIView清理观察器/计时器 - 协调器与闭包之间不存在保留循环(
[weak coordinator]) - 正确将
UIHostingController添加为子控制器(addChild+didMove(toParent:)) - 已选择尺寸策略(
intrinsicContentSize、固定frame或sizeThatFits) - 将表示视图的几何信息交由 SwiftUI 处理(不直接修改
frame、bounds、center、transform) - 在需要时,通过
context.environment在updateUIView中读取环境值 - UIKit 对
@Observable的读取使用 iOS 18/26 及更高版本的自动跟踪钩子,而非轮询;iOS 17 仅支持手动跟踪一次 - 为严格并发模式将协调器标记为
@MainActor - 模态控制器在所有委托退出路径中都会关闭(成功、取消、错误)
- 对于集合视图/表格视图单元格,使用
UIHostingConfiguration,而非手动宿主方式(iOS 16 及更高版本)
References
- Wrapping recipes: references/representable-recipes.md
- Migration patterns: references/hosting-migration.md
- Apple docs: UIViewRepresentable
- Apple docs: UIViewControllerRepresentable
- Apple docs: UIHostingController