网站首页 > 教程文章 正文
从零开始的 SwiftUI 互操作:Hosting、UIViewRepresentable、Coordinator、数据模型到拖放与命令体系
引言:为什么互操作是 SwiftUI 的制胜关键?
SwiftUI 的目标之一就是渐进式采纳(incremental adoption):
- o 你的 App 早已有大量 UIKit/AppKit 代码?可以从某个页面或模块先用 SwiftUI 写起,再通过 UIHostingController 或 Representable 把两边连起来。
- o 你需要继续复用以往积累的控件、VC、数据模型、甚至 Objective-C 代码?SwiftUI 提供了系统化的桥接协议与上下文(Coordinator/Environment/Transaction)。
- o 你还要用到拖拽、粘贴、数字表冠(watchOS)或 tvOS 的遥控命令?SwiftUI 同样支持,并把这些交互抽象成可组合的修饰符与环境值。
一句话:视图是状态的函数,互操作是工程落地的生产力。下面从最常见的两条路径开讲:把 SwiftUI 放进 UIKit、把 UIKit 放进 SwiftUI。
一、把 SwiftUI 放进 UIKit:UIHostingController
1.1 UIKit → SwiftUI:用 Hosting Controller 承载 rootView
你可以在任何 UIKit 容器里嵌入 SwiftUI 视图,例如从 UITableViewController 点击进入一个详情页 PlantDetailsView(SwiftUI 实现)。
# Swift
import UIKit
import SwiftUI
// 你的 SwiftUI 视图
struct PlantDetailsView: View {
let plant: Plant
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(plant.name).font(.title2.bold())
Text(plant.summary).foregroundStyle(.secondary)
Spacer()
}
.padding()
.navigationTitle("Plant")
}
}
// UIKit 表格控制器中跳转
final class PlantsTableViewController: UITableViewController {
var plants: [Plant] = Plant.samples
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let plant = plants[indexPath.row]
let details = PlantDetailsView(plant: plant)
// 用 Hosting Controller 包装 SwiftUI 视图
let host = UIHostingController(rootView: details)
// 如果在导航栈中:
navigationController?.pushViewController(host, animated: true)
// 或者 present:
// present(UINavigationController(rootViewController: host), animated: true)
}
}
要点
o Hosting Controller 的 rootView 即 SwiftUI 的入口。o 你仍可使用 UIKit 的导航、转场、手势、状态栏等系统。o Storyboard/IBSegueActions 也能便捷集成(此处略)。
二、把 UIKit 放进 SwiftUI:Representable 协议全景图
2.1 三大家族与两个层级
- o View 层:
- o UIViewRepresentable(iOS/tvOS)
- o NSViewRepresentable(macOS)
- o WKInterfaceObjectRepresentable(watchOS,子集)
- o ViewController 层:
- o UIViewControllerRepresentable
- o NSViewControllerRepresentable
2.2 协议方法的生命周期与职责
- o Make 方法(makeUIView / makeNSView / makeUIViewController …):创建一次底层对象。
- o Update 方法(updateUIView / …):SwiftUI 请求更新时多次调用,把当前配置/状态同步给底层对象。
- o Dismantle 方法(可选):对象移除前的清理。
- o makeCoordinator(可选):创建 Coordinator,用于目标-动作 / 委托 / 数据源等桥接。
- o 上下文 Context 提供:
- o coordinator(若实现了 makeCoordinator)
- o environment(环境信息,如颜色模式、Size Class、方向等)
- o transaction(更新是否包含动画等)
心智模型:Make 负责“造物”,Update 负责“喂数据”,Coordinator 负责“沟通双方”,Dismantle 负责“好聚好散”。
三、示例实战:把 UIKit 的五星评分控件嵌入 SwiftUI
我们实现一个 UIKitRatingsControl(五角星评分),并把它包装为 SwiftUI 可用的
RatingsControlRepresentation。
目标:
- o 从 SwiftUI 传入 @Binding<Int> 评分值,让 UIKit 显示正确高亮;
- o 当用户点星改变评分时,通过 target-action + Coordinator 把新值回写到 SwiftUI。
# Swift
import SwiftUI
import UIKit
// 假设已有 UIKit 控件
final class UIKitRatingsControl: UIControl {
var rating: Int = 0 { didSet { setNeedsLayout() } }
// ... 自绘/子视图布局,略
// 在用户点选时触发:
private func userDidChange(to newValue: Int) {
rating = newValue
sendActions(for: .valueChanged)
}
}
// SwiftUI 包装器
struct RatingsControlRepresentation: UIViewRepresentable {
@Binding var rating: Int // ← SwiftUI 端的单一事实来源(绑定)
// 1) Coordinator:桥接 target-action / delegate / datasource
final class Coordinator: NSObject {
var rating: Binding<Int>
init(rating: Binding<Int>) { self.rating = rating }
@objc func ratingChanged(_ sender: UIKitRatingsControl) {
rating.wrappedValue = sender.rating // 回写到 SwiftUI 绑定
}
}
func makeCoordinator() -> Coordinator {
Coordinator(rating: $rating)
}
// 2) 创建 UIKit 视图
func makeUIView(context: Context) -> UIKitRatingsControl {
let view = UIKitRatingsControl()
// 目标-动作
view.addTarget(context.coordinator,
action: #selector(Coordinator.ratingChanged(_:)),
for: .valueChanged)
return view
}
// 3) 更新 UIKit 视图以匹配 SwiftUI 当前配置/状态
func updateUIView(_ uiView: UIKitRatingsControl, context: Context) {
// 从 Binding 读值 → 显示
if uiView.rating != rating {
uiView.rating = rating
}
// 你还可读取环境与事务:
// let isDark = context.environment.colorScheme == .dark
// let animated = context.transaction.animation != nil
}
// 4) 可选清理
static func dismantleUIView(_ uiView: UIKitRatingsControl, coordinator: Coordinator) {
uiView.removeTarget(nil, action: nil, for: .allEvents)
}
}
// SwiftUI 中使用
struct PlantDetailsView: View {
@State private var rating = 3
var body: some View {
HStack(spacing: 16) {
RatingsControlRepresentation(rating: $rating)
.frame(width: 160, height: 32) // SwiftUI 布局约束传递给 UIKit
Text("评分:\(rating)")
}
.padding()
}
}
关键点
o 单一事实来源在 SwiftUI 侧(@State/@Binding)。UIKit 只显示与上报。o 双向同步路径:SwiftUI → updateUIView → UIKit;UIKit → target-action → Coordinator → 回写 Binding。o 通过 context.environment 读取环境;用 context.transaction 判断是否处于动画更新,以决定 UIKit 是否执行动画。
四、嵌入 UIKit 控制器:UIViewControllerRepresentable
有时你要复用现成的 VC(例如相册选取/分享面板/地图导航等)。做法与 View 层相似:
# Swift
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
// 一般无需更新;有需要可根据环境/事务调整
}
}
// 使用
struct ArticleView: View {
@State private var show = false
var body: some View {
Button("分享") { show = true }
.sheet(isPresented: $show) {
ShareSheet(items: ["Hello SwiftUI"])
}
}
}
小贴士
o
NSViewControllerRepresentable 同理(macOS)。o 控制器常含系统交互(授权、转场),复用成本比“重写一遍 SwiftUI 版”低得多。
五、Representable × Environment/Transaction 的高级玩法
- o Environment:你可为 UIKit 控件启用暗色模式、动态字体、布局方向(RTL)等行为,避免双端打架。
- o Transaction:当 SwiftUI 更新包含动画时(withAnimation/animation(value:)),transaction.animation != nil,此时你可让 UIKit 也用相同节奏过渡,减少割裂感。
# Swift
func updateUIView(_ uiView: UIKitRatingsControl, context: Context) {
let animated = context.transaction.animation != nil
if animated {
UIView.transition(with: uiView, duration: 0.25, options: .transitionCrossDissolve) {
uiView.rating = rating
}
} else {
uiView.rating = rating
}
}
六、数据模型互通:从早期 BindableObject → 现代 ObservableObject
字幕中提到的 BindableObject / @ObjectBinding 是 SwiftUI 早期 API。现代写法请使用:
- o ObservableObject + @Published
- o 视图侧用 @StateObject(拥有者)/ @ObservedObject(观察者)/ @EnvironmentObject(跨层共享)
6.1 模型(现代)
# Swift
import Combine
@MainActor
final class PlantsStore: ObservableObject {
@Published var plants: [Plant] = []
@Published var selected: Plant?
// 从云/本地同步时,只需改 Published 属性,SwiftUI 自动刷新
}
6.2 视图(现代)
# Swift
struct PlantsRootView: View {
@StateObject private var store = PlantsStore() // 拥有者(生命周期托管)
var body: some View {
NavigationStack {
List(store.plants) { plant in
NavigationLink(plant.name, value: plant)
}
.navigationDestination(for: Plant.self) { plant in
PlantDetailsView(plant: plant)
}
}
.environmentObject(store) // 向下广播(多层可读)
}
}
struct SomeChildView: View {
@EnvironmentObject var store: PlantsStore // 任意子树中消费
var body: some View { Text("共 \(store.plants.count) 种植物") }
}
6.3 兼容旧模型:用 Notification/Combine Publisher 驱动
你的旧数据层可能已发 Notification 或 KVO。可用 Combine 把它们接到 ObservableObject:
# Swift
final class LegacyModelWrapper: ObservableObject {
private var bag = Set<AnyCancellable>()
private let legacy = LegacyModel.shared
@Published var plants: [Plant] = []
init() {
NotificationCenter.default.publisher(for: .plantsDidChange, object: legacy)
.receive(on: RunLoop.main) // UI 必须主线程
.sink { [weak self] _ in self?.plants = self?.legacy.allPlants() ?? [] }
.store(in: &bag)
}
}
要点
o 主线程更新:.receive(on: RunLoop.main)。o @StateObject 托管生命周期,避免重建导致订阅重复或状态丢失。o 若你还在使用 BindableObject/didChange,尽快迁移到 ObservableObject/@Published。
七、拖拽、粘贴板与 ItemProvider:onDrag / onDrop / onPasteCommand
在 SwiftUI 里,拖拽/粘贴由 NSItemProvider(或 UTType)承载数据。你只需声明接受的类型与如何提供数据。
# Swift
import UniformTypeIdentifiers
struct DraggableTag: View {
let plant: Plant
var body: some View {
Text(plant.name)
.padding(8)
.background(.green.opacity(0.2))
.cornerRadius(8)
.onDrag {
// 提供可拖拽数据
let provider = NSItemProvider(object: plant.name as NSString)
provider.suggestedName = plant.name
return provider
}
}
}
struct DropTarget: View {
@State private var droppedNames: [String] = []
var body: some View {
VStack {
Text("已接收:\(droppedNames.joined(separator: ", "))")
Rectangle().strokeBorder(.blue, style: StrokeStyle(lineWidth: 2, dash: [6]))
.frame(height: 120)
.onDrop(of: [UTType.text.identifier], isTargeted: nil) { providers in
// 处理 drop
providers.first?.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, _ in
if let data = item as? Data, let str = String(data: data, encoding: .utf8) {
DispatchQueue.main.async { droppedNames.append(str) }
} else if let str = item as? String {
DispatchQueue.main.async { droppedNames.append(str) }
}
}
return true
}
}
.padding()
}
}
粘贴命令:onPasteCommand(无坐标、依赖焦点)
# Swift
struct PasteConsumer: View {
@State private var pasted = ""
var body: some View {
TextEditor(text: $pasted)
.frame(height: 100)
.border(.secondary)
.onPasteCommand(of: [.plainText]) { providers in
providers.first?.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
if let str = item as? String {
DispatchQueue.main.async { pasted += str }
}
}
}
}
}
差异
o onDrop 有落点坐标;onPasteCommand 没有坐标,依赖**焦点系统(focus)**决定由谁处理。
八、焦点与命令体系:focusable / onCommand / watchOS & tvOS
8.1 焦点系统(Focus)
- o 一些平台/场景(macOS 键盘、tvOS 遥控、iOS 硬件键盘/手势)需要“当前焦点”。
- o 用 .focusable(true) 让自定义视图可获得焦点;可监听聚焦/失焦以提供提示。
# Swift
struct FocusTile: View {
@State private var focused = false
var body: some View {
Text(focused ? "Focused" : "Focusable")
.padding().background(focused ? .blue.opacity(0.2) : .gray.opacity(0.1))
.cornerRadius(8)
.focusable(true) { newValue in
focused = newValue
}
}
}
8.2 菜单/快捷键命令:onCommand
- o macOS 菜单项、Toolbar 的第一响应者链可以通过 .onCommand(#selector(...)) 连接到 SwiftUI。
- o tvOS 也有 .onPlayPauseCommand、.onExitCommand 等专用修饰符。
# Swift
struct CommandConsumer: View {
@State private var count = 0
var body: some View {
Text("Count: \(count)")
.onCommand(#selector(NSResponder.insertNewline(_:))) {
count += 1
}
}
}
8.3 watchOS 数字表冠
# Swift
struct CrownStepper: View {
@State private var value = 3.0
var body: some View {
Text("Score: \(Int(value))")
.digitalCrownRotation($value, from: 0, through: 10, by: 1)
.focusable()
}
}
九、Undo 管理器:@Environment(.undoManager)
SwiftUI 与系统同用 UndoManager。多数情况你在数据层注册撤销即可;必要时在视图里拿到环境:
# Swift
struct EditableName: View {
@Environment(\.undoManager) private var undoManager
@State private var name = "Hibiscus"
var body: some View {
TextField("名称", text: $name)
.onSubmit {
let old = name
let new = name.trimmingCharacters(in: .whitespaces)
name = new
undoManager?.registerUndo(withTarget: self) { target in
target.name = old
}
}
}
}
十、Objective-C 互操作:VC/数据模型的双向桥接
10.1 从 Obj-C 里展示 SwiftUI:封装 Swift Hosting VC
# Swift (Swift 文件)
import SwiftUI
@objc final class PlantsHostController: UIViewController {
private let hosting: UIHostingController<PlantsRootView>
@objc init(plantID: NSString?) {
let root = PlantsRootView() // 可在 init 中把 id 传给 SwiftUI
hosting = UIHostingController(rootView: root)
super.init(nibName: nil, bundle: nil)
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.frame = view.bounds
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hosting.didMove(toParent: self)
}
required init?(coder: NSCoder) { fatalError() }
}
# Objective-C (.m)
#import "YourModuleName-Swift.h" // Swift 公开头
- (void)presentSwiftUI {
PlantsHostController *vc = [[PlantsHostController alloc] initWithPlantID:nil];
[self.navigationController pushViewController:vc animated:YES];
}
10.2 Obj-C 数据模型 → SwiftUI:用 Combine 封装
# Swift
import Combine
@objc class ObjCDataModel: NSObject
// 内部会发 NSNotification 或 KVO 变更,略
final class WrappedDataModel: ObservableObject {
private var bag = Set<AnyCancellable>()
let objC: ObjCDataModel
@Published var plants: [Plant] = []
init(objC: ObjCDataModel) {
self.objC = objC
NotificationCenter.default.publisher(for: .plantsDidChange, object: objC)
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.plants = objC.allPlants() }
.store(in: &bag)
}
}
要点
o SwiftUI 侧只关心 ObservableObject;Obj-C 细节藏在 Wrapper 内。o 依然遵守单一事实来源:把数据权威留在模型层,通过发布者向上游输送。
十一、最佳实践清单(互操作篇)
- 1. 先定“所有权”:SwiftUI 端是否拥有数据/控件的生命周期?拥有则 @StateObject/makeUIView 创建;不拥有则只观察/包装。
- 2. Binding 优先:SwiftUI 是状态之源,UIKit 通过 target-action/代理把改变回写 Binding。
- 3. Update 幂等:updateUIView/updateController 写法应幂等;只在必要时变更底层以避免抖动。
- 4. Context 用起来:environment 与 transaction 提升一致性(暗黑、字体、动画对齐)。
- 5. Coordinator 专注桥接:目标-动作/委托/数据源都放进 Coordinator,保持包装器纯净。
- 6. 主线程保证:任何 UI 写入均在主线程;Combine 记得 .receive(on: RunLoop.main)。
- 7. 可拆可测:UIKit 控件与 SwiftUI 包装解耦,便于分别测试与重用。
- 8. 尽量复用系统 VC:相册/分享/文档选择器优先 UIViewControllerRepresentable 包装,少造轮子。
- 9. 避免复制状态:不要在 UIKit 与 SwiftUI 两边各存一份真相;以 Binding/Publisher 为纽带。
- 10. 逐步迁移:从单页或单控件切入,持续扩展 Representable 层;切忌一口吃成胖子。
十二、常见坑与排查
- o 星星能点但标签不更新:只实现了 updateUIView,没把 UIKit 的变更通过 Coordinator 回写到 @Binding。
- o 状态丢失/重建频繁:模型放在子视图用 @ObservedObject 创建 → 改为父视图 @StateObject 持有,再下传。
- o 动画割裂:SwiftUI 有动画,UIKit 没同步 → 在 update 里用 context.transaction.animation 驱动 UIKit 过渡。
- o 主线程警告/崩溃:Publisher/回调在后台线程回写 UI → .receive(on: RunLoop.main) 或 DispatchQueue.main.async。
- o 拖放无响应:UTI/UTType 不匹配;或未解析 NSItemProvider 的具体数据类型。
- o onPasteCommand 不触发:缺乏焦点;确保目标视图 .focusable(true) 或可成为第一响应者。
- o Obj-C 与 SwiftUI 不联动:忘了把通知/KVO 接到 Combine 发布者,并上送到 @Published。
十三、思考题
- 1. 把你项目里一个自研的 UIKit 控件(如颜色拾取器)包装成 UIViewRepresentable,支持 @Binding<Color> 与暗黑模式。
- 2. 选一个现有 UIViewController(如 UIImagePickerController),写成 UIViewControllerRepresentable,并把选图结果通过 Combine/Binding 回传。
- 3. 为包装器加入动画对齐:当 SwiftUI 端 withAnimation 时,让 UIKit 端也用相同时长/曲线过渡。
- 4. 设计一个拖拽到列表排序的界面:onDrag 携带行 id,onDrop 接收位置并在模型层更新顺序。
- 5. 把一个 Obj-C 模型用 ObservableObject 包起:NotificationCenter → Combine Publisher → @Published,并写一个 SwiftUI 列表实时刷新。
知识小结
- o 双向互通的两扇门:UIHostingController(UIKit 承载 SwiftUI)、Representable(SwiftUI 承载 UIKit/AppKit/WatchKit)。
- o Representable 四件套:make(建)、update(喂)、coordinator(桥)、dismantle(散);Context 提供 environment/transaction。
- o 数据统一:SwiftUI 为源,UIKit 为投影;通过 @Binding 与 target-action/委托把修改回写。
- o 现代数据流:ObservableObject + @Published + @StateObject/@ObservedObject/@EnvironmentObject;弃用旧 BindableObject/@ObjectBinding。
- o 系统能力:拖放(onDrag/onDrop)、粘贴(onPasteCommand)、焦点与命令(focusable/onCommand)、Undo(@Environment(\.undoManager))。
- o Objective-C 友好:用 Swift 包裹 Hosting VC 与模型 Wrapper,Combine 连接通知/KVO。
- o 最终原则:视图是状态的函数;互操作让你把“正确而无聊的兼容/适配”交给框架,把精力放在“独特而有趣的体验”上。
猜你喜欢
- 2025-09-21 快速了解JavaScript的基础知识_javascript 基础
- 2025-09-21 陌生APP拿到你的摄像头权限后拿到你的“裸照”有多容易
- 2025-09-21 数据结构必修:链表核心操作与 LRU 设计,一篇图解吃透
- 2025-09-21 原 顶 ECMAScript6入门 学习之简介
- 2025-09-21 Rust元编程: 让你的代码在编译时开始「自我繁殖」
- 2025-09-21 别再让误操作背锅!常见防误操作程序底层逻辑,工程师必收藏
- 2025-09-21 Javascript简介和基础数据类型_javascript的数据类型主要包括
- 2025-09-21 Rust中的Condvar条件变量:让线程"听话"的魔法棒
- 2025-09-21 一举两得学编程:Rust 与 Zig 对比学习教程
- 2025-09-21 面试被问 const 是否不可变?这样回答才显功底
- 最近发表
- 标签列表
-
- location.href (44)
- document.ready (36)
- git checkout -b (34)
- 跃点数 (35)
- 阿里云镜像地址 (33)
- qt qmessagebox (36)
- mybatis plus page (35)
- vue @scroll (38)
- 堆栈区别 (33)
- 什么是容器 (33)
- sha1 md5 (33)
- navicat导出数据 (34)
- 阿里云acp考试 (33)
- 阿里云 nacos (34)
- redhat官网下载镜像 (36)
- srs服务器 (33)
- pico开发者 (33)
- https的端口号 (34)
- vscode更改主题 (35)
- 阿里云资源池 (34)
- os.path.join (33)
- redis aof rdb 区别 (33)
- 302跳转 (33)
- http method (35)
- js array splice (33)