云计算、AI、云原生、大数据等一站式技术学习平台

网站首页 > 教程文章 正文

从零开始的 SwiftUI 互操作_swiftui dsl

jxf315 2025-09-21 17:12:54 教程文章 2 ℃

从零开始的 SwiftUI 互操作:Hosting、UIViewRepresentable、Coordinator、数据模型到拖放与命令体系


引言:为什么互操作是 SwiftUI 的制胜关键?

SwiftUI 的目标之一就是渐进式采纳(incremental adoption):

  • o 你的 App 早已有大量 UIKit/AppKit 代码?可以从某个页面或模块先用 SwiftUI 写起,再通过 UIHostingControllerRepresentable 把两边连起来。
  • 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 → 回写 Bindingo 通过 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. 1. 先定“所有权”:SwiftUI 端是否拥有数据/控件的生命周期?拥有则 @StateObject/makeUIView 创建;不拥有则只观察/包装。
  2. 2. Binding 优先:SwiftUI 是状态之源,UIKit 通过 target-action/代理把改变回写 Binding。
  3. 3. Update 幂等updateUIView/updateController 写法应幂等;只在必要时变更底层以避免抖动。
  4. 4. Context 用起来environmenttransaction 提升一致性(暗黑、字体、动画对齐)。
  5. 5. Coordinator 专注桥接:目标-动作/委托/数据源都放进 Coordinator,保持包装器纯净。
  6. 6. 主线程保证:任何 UI 写入均在主线程;Combine 记得 .receive(on: RunLoop.main)
  7. 7. 可拆可测:UIKit 控件与 SwiftUI 包装解耦,便于分别测试与重用。
  8. 8. 尽量复用系统 VC:相册/分享/文档选择器优先 UIViewControllerRepresentable 包装,少造轮子。
  9. 9. 避免复制状态:不要在 UIKit 与 SwiftUI 两边各存一份真相;以 Binding/Publisher 为纽带。
  10. 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. 1. 把你项目里一个自研的 UIKit 控件(如颜色拾取器)包装成 UIViewRepresentable,支持 @Binding<Color> 与暗黑模式。
  2. 2. 选一个现有 UIViewController(如 UIImagePickerController),写成 UIViewControllerRepresentable,并把选图结果通过 Combine/Binding 回传。
  3. 3. 为包装器加入动画对齐:当 SwiftUI 端 withAnimation 时,让 UIKit 端也用相同时长/曲线过渡。
  4. 4. 设计一个拖拽到列表排序的界面:onDrag 携带行 id,onDrop 接收位置并在模型层更新顺序。
  5. 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 最终原则视图是状态的函数;互操作让你把“正确而无聊的兼容/适配”交给框架,把精力放在“独特而有趣的体验”上。

最近发表
标签列表