mcpField
S
swiftui-uikit-interop

swiftui-uikit-interop

github.com/dpearson2699开发1.2k4.1k 次安装13 周前更新
代码仓库

关于 swiftui-uikit-interop

面向 iOS 开发者的 SwiftUI 与 UIKit 互操作指南,涵盖双向封装、生命周期、布局和状态同步,并说明常见陷阱及迁移模式。

主要功能

  1. 01将 UIView 和控制器封装到 SwiftUI
  2. 02在 UIKit 中嵌入 SwiftUI
  3. 03通过 Coordinator 传递委托事件
  4. 04同步绑定、环境值与视图状态
  5. 05处理布局尺寸和生命周期清理

适用场景

  1. 01在 SwiftUI 中使用 UIKit 系统选择器
  2. 02把 SwiftUI 页面加入现有 UIKit 屏幕
  3. 03同步文本视图等控件的双向状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-uikit-interop

安装后在 Claude Code 中输入/swiftui-uikit-interop确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
references/
hosting-migration.md
representable-recipes.md

详细介绍

SwiftUI-UIKit 互操作

双向桥接 UIKit 和 SwiftUI:封装 UIKit 视图和控制器、将 SwiftUI 嵌入 UIKit 屏幕,并在不重复管理生命周期的情况下同步状态。

完整的封装示例请参阅 references/representable-recipes.md,UIKit 到 SwiftUI 的迁移模式请参阅 references/hosting-migration.md。

目录

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()
        }
    }
}
关键规则
  1. 在 makeUIView/makeUIViewController 中设置代理,绝不要在 updateUIView 中设置。 对所表示视图的状态变更可能导致更新方法运行多次;在其中设置代理会造成重复赋值,并可能触发意外副作用。

  2. 自行刷新复制的父级状态。 如果协调器通过 var parent 存储可表示对象,请在 updateUIView 或 updateUIViewController 开始时赋值 context.coordinator.parent = self。绑定仍指向其真实数据源,但闭包和非绑定值会被复制到协调器中。

  3. 在闭包中使用 [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

评论

支持 Markdown