WWDC Quick Look 💓 By SwiftGGTeam
Use SwiftUI with UIKit

Use SwiftUI with UIKit

观看原视频

Highlight

Health 应用团队的 Sara Frederixon 以亲身经验讲解了如何在现有 UIKit 应用中逐步引入 SwiftUI。Session 覆盖了四个方面:UIHostingController 的更新、SwiftUI 视图与现有数据源的对接、用 SwiftUI 构建 Collection View 和 Table View cell、以及混合使用时的数据流管理。


核心内容

很多 iOS 应用已经用 UIKit 写了多年。数据模型、view controller、collection view、table view 都在工作。团队想用 SwiftUI 写新界面时,最怕的事情是迁移范围失控:为了一个新 cell,牵动整套页面结构。

Health 应用也遇到这个问题。它有大量健康数据可视化,UIKit 代码已经承载了现有模型和导航。Sara Frederixon 在开场说明,这场 session 的目标,是把 SwiftUI 放进已有 UIKit 应用,而不要求重写应用结构(00:18)。

Apple 给出的路径分成三步:用 UIHostingController 把 SwiftUI 视图当作 view controller 使用;用 ObservableObject 把 UIKit 侧已有数据桥接给 SwiftUI;用 iOS 16 新增的 UIHostingConfiguration 直接在 collection view 和 table view cell 里写 SwiftUI(00:50)。

详细内容

UIHostingController 仍然是入口

UIHostingController 是一个包含 SwiftUI view hierarchy 的 UIViewController。它能出现在 UIKit 里任何需要 view controller 的地方,比如 modal、popover、child view controller(01:33)。

// Presenting a UIHostingController

let heartRateView = HeartRateView() // a SwiftUI view
let hostingController = UIHostingController(rootView: heartRateView)

// Present the hosting controller modally
self.present(hostingController, animated: true)

关键点:

  • HeartRateView() 是 SwiftUI 视图,UIKit 不需要知道它内部的布局方式。
  • UIHostingController(rootView:) 把 SwiftUI 视图包装成 UIKit 的 view controller。
  • present(_:animated:) 仍然是标准 UIKit API,调用方式没有变化。

如果页面需要嵌入而非弹出,可以把 hosting controller 加成 child view controller(02:31)。

// Embedding a UIHostingController

let heartRateView = HeartRateView() // a SwiftUI view
let hostingController = UIHostingController(rootView: heartRateView)

// Add the hosting controller as a child view controller
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)

// Now position & size the hosting controller’s view as desired…

关键点:

  • addChild(_:) 把 SwiftUI 承载控制器接入 UIKit 的 view controller 层级。
  • addSubview(_:) 只负责把承载视图放到当前 view 中。
  • didMove(toParent:) 完成 child view controller 生命周期通知。
  • 最后一行提醒开发者继续用 UIKit 的布局方式定位和约束这个 view。

iOS 16 给 UIHostingController 增加了 sizingOptions。SwiftUI 内容变化时,hosting controller 可以自动更新 preferredContentSize 或 view 的 intrinsic content size。popover 这类依赖内容尺寸的场景会直接受益(02:43)。

// Presenting UIHostingController as a popover

let heartRateView = HeartRateView() // a SwiftUI view
let hostingController = UIHostingController(rootView: heartRateView)

// Enable automatic preferredContentSize updates on the hosting controller
hostingController.sizingOptions = .preferredContentSize

hostingController.modalPresentationStyle = .popover
self.present(hostingController, animated: true)

关键点:

  • sizingOptions = .preferredContentSize 要求 hosting controller 根据 SwiftUI 内容更新 preferred content size。
  • modalPresentationStyle = .popover 让 UIKit 用 popover 形式呈现。
  • SwiftUI 内容尺寸改变后,popover 可以继续贴合内容。

外部数据先用值传入,再用 ObservableObject 自动更新

UIKit 应用里的数据通常由已有 model layer 持有。SwiftUI 视图只是新加入的界面层,所以 @State@StateObject 这种由 SwiftUI 创建并拥有的数据并不适合这里。session 先给出最直接的做法:把值传给 SwiftUI view,再在 UIKit 侧手动更新 hosting controller 的 rootView05:06)。

// Passing data to SwiftUI with manual UIHostingController updates

struct HeartRateView: View {
    var beatsPerMinute: Int

    var body: some View {
        Text("\(beatsPerMinute) BPM")
    }
}

class HeartRateViewController: UIViewController {
    let hostingController: UIHostingController< HeartRateView >
    var beatsPerMinute: Int {
        didSet { update() }
    }

    func update() {
        hostingController.rootView = HeartRateView(beatsPerMinute: beatsPerMinute)
    }
}

关键点:

  • HeartRateView 只接收一个 Int,它本身不拥有数据来源。
  • Text("\(beatsPerMinute) BPM") 用传入值生成界面。
  • beatsPerMinute 在 UIKit view controller 中保存。
  • didSet { update() } 表示 UIKit 侧数据变化后要主动触发刷新。
  • hostingController.rootView = ... 重新创建 SwiftUI root view,把最新值送进去。

这种方式简单,但每次数据变化都要手动更新。session 接着建议把已有模型改成 ObservableObject,把会变化的属性标记为 @Published,再让 SwiftUI 视图用 @ObservedObject 读取它(06:49)。

// Passing an ObservableObject to automatically update SwiftUI views

class HeartData: ObservableObject {
    @Published var beatsPerMinute: Int

    init(beatsPerMinute: Int) {
       self.beatsPerMinute = beatsPerMinute
    }
}

struct HeartRateView: View {
    @ObservedObject var data: HeartData

    var body: some View {
        Text("\(data.beatsPerMinute) BPM")
    }
}

关键点:

  • HeartData: ObservableObject 让 SwiftUI 可以观察这个外部模型。
  • @Published var beatsPerMinute 是触发刷新信号的属性。
  • @ObservedObject var data 把外部模型接进 SwiftUI view。
  • Text("\(data.beatsPerMinute) BPM") 直接读取模型属性。
  • 属性变化后,SwiftUI 自动刷新相关视图,UIKit 不需要再改 rootView

UIKit view controller 初始化时只需要保存同一个 model,并把它传给 SwiftUI view(08:30)。

// Passing an ObservableObject to automatically update SwiftUI views

class HeartRateViewController: UIViewController {
    let data: HeartData
    let hostingController: UIHostingController<HeartRateView>

    init(data: HeartData) {
        self.data = data
        let heartRateView = HeartRateView(data: data)
        self.hostingController = UIHostingController(rootView: heartRateView)
    }
}

关键点:

  • data 是 UIKit 侧持有的同一个 HeartData 实例。
  • HeartRateView(data: data) 把模型引用交给 SwiftUI。
  • UIHostingController(rootView:) 只在初始化时包装 SwiftUI view。
  • 后续心率样本从 78 变成 94 时,@Published 会驱动 SwiftUI 更新界面(09:14)。

UIHostingConfiguration 把 SwiftUI 放进 cell

iOS 16 新增 UIHostingConfiguration,它是一个 cell content configuration。开发者把它赋给 UICollectionViewCellUITableViewCellcontentConfiguration,就能在 cell 里直接写 SwiftUI。这里不需要额外嵌入 view 或 view controller(09:52)。

cell.contentConfiguration = UIHostingConfiguration {
  // Start writing SwiftUI here!
}

关键点:

  • contentConfiguration 是 UIKit cell 的现代配置入口。
  • UIHostingConfiguration { ... } 接收 SwiftUI ViewBuilder
  • 花括号内部直接写 SwiftUI view。

session 用心率 cell 展示了迁移收益。原来复杂的自定义 cell 布局,可以拆成几个 SwiftUI 小视图(11:02)。

// Building a custom cell using SwiftUI with UIHostingConfiguration

cell.contentConfiguration = UIHostingConfiguration {
    HeartRateTitleView()
}

struct HeartRateTitleView: View {
    var body: some View {
        HStack {
            Label("Heart Rate", systemImage: "heart.fill")
                .foregroundStyle(.pink)
                .font(.system(.subheadline, weight: .bold))
            Spacer()
            Text(Date(), style: .time)
                .foregroundStyle(.secondary)
                .font(.footnote)
        }
    }
}

关键点:

  • HeartRateTitleView() 被放进 UIHostingConfiguration,成为 cell 内容。
  • Label("Heart Rate", systemImage:) 显示标题和系统图标。
  • .foregroundStyle(.pink).font(...) 使用标准 SwiftUI modifier 调整样式。
  • Spacer() 把标题推到左侧,把时间推到右侧。
  • Text(Date(), style: .time) 用 SwiftUI 的日期文本显示当前时间。

继续扩展时,只要在 configuration 中组合 VStackHStack 和其他 SwiftUI view。session 还把 Swift Charts 的 Chart 放进 cell,用 LineMark 显示心率曲线(13:25)。

// Building a custom cell using SwiftUI with UIHostingConfiguration

cell.contentConfiguration = UIHostingConfiguration {
    VStack(alignment: .leading) {
        HeartRateTitleView()
        Spacer()
        HStack(alignment: .bottom) {
            HeartRateBPMView()
            Spacer()
            Chart(heartRateSamples) { sample in
                LineMark(x: .value("Time", sample.time),
                         y: .value("BPM", sample.beatsPerMinute))
                   .symbol(Circle().strokeBorder(lineWidth: 2))
                   .foregroundStyle(.pink)
            }
        }
    }
}

关键点:

  • VStack(alignment: .leading) 组织 cell 的纵向内容。
  • HeartRateTitleView() 复用前面提取的标题视图。
  • HeartRateBPMView() 显示当前心率数字。
  • Chart(heartRateSamples) 以样本集合驱动图表。
  • LineMark 把每个样本映射到时间和 BPM 两个轴。
  • .symbol(...).foregroundStyle(.pink) 调整折线点和颜色。

cell 的边距、背景、滑动动作和选中态

UIHostingConfiguration 支持几类 cell 场景常用能力。默认情况下,根 SwiftUI 内容会根据 UIKit cell 的 layout margins 内缩。如果需要改边距,可以在 configuration 后加 .margins14:37)。

cell.contentConfiguration = UIHostingConfiguration {
    HeartRateBPMView()
}
.margins(.horizontal, 16)

关键点:

  • HeartRateBPMView() 是 cell 的 SwiftUI 内容。
  • .margins(.horizontal, 16) 覆盖水平方向默认边距。
  • 这个 modifier 的作用对象是 UIHostingConfiguration,而非单独的 UIKit view。

背景也能直接用 SwiftUI modifier 设置。session 说明背景托管在 cell 背面,并且背景会延伸到 cell 边缘;self-sizing cell 的尺寸只由内容决定,背景不参与决定尺寸(15:16)。

cell.contentConfiguration = UIHostingConfiguration {
   HeartTitleView()
}
.background(.pink)

关键点:

  • HeartTitleView() 是前景内容。
  • .background(.pink) 设置 configuration 的背景。
  • 背景位于 cell content view 中 SwiftUI 内容的后面。

列表里的 swipe actions 也可以直接写在 SwiftUI view 上。Apple 特别提醒,按钮执行动作时要使用 item 的稳定标识符,不要使用 index path;index path 在 cell 可见期间也可能变化(16:32)。

cell.contentConfiguration = UIHostingConfiguration {
    MedicalConditionView()
        .swipeActions(edge: .trailing) {  }
}

关键点:

  • MedicalConditionView() 是某个医疗状况 cell 的 SwiftUI 内容。
  • .swipeActions(edge: .trailing) 在列表右侧滑动时显示操作。
  • { … } 中放按钮和业务动作。
  • 业务动作应绑定到稳定 item identifier,避免误操作其他行。

如果 SwiftUI 内容要响应 UIKit cell 的选中态或高亮态,需要在 configurationUpdateHandler 里重新创建 configuration,并读取 UIKit 提供的 state17:25)。

// Incorporating UIKit cell states

cell.configurationUpdateHandler = { cell, state in
    cell.contentConfiguration = UIHostingConfiguration {
      HStack {
        HealthCategoryView()
            Spacer()
            if state.isSelected {
                Image(systemName: "checkmark")
            }
        }
    }
}

关键点:

  • configurationUpdateHandler 会在 cell 状态变化时再次运行。
  • state 包含选中、高亮等 UIKit cell 状态。
  • UIHostingConfiguration 在 handler 内重建,所以 SwiftUI 内容能使用最新状态。
  • if state.isSelected 只在选中时显示 checkmark。

diffable data source 只管集合变化,ObservableObject 负责 cell 内刷新

当 collection view 或 table view 的 cell 由 SwiftUI 构建时,数据流要分两类处理。第一类是集合变化,比如插入、删除、重排;继续用 diffable data source snapshot 处理。snapshot 里应该放每个 MedicalCondition 的唯一标识符,而非 model 对象本身,这样 diffable data source 才能准确计算 identity(18:41)。

第二类是单个 model 的属性变化。传统 UIKit 做法通常要 reconfigure 或 reload snapshot 里的 item。使用 SwiftUI cell 时,可以把 model 做成 ObservableObject,让 SwiftUI view 用 @ObservedObject 观察它。@Published 属性变化后,cell 内 SwiftUI view 会直接刷新,不需要先经过 diffable data source 或 collection view(20:35)。

iOS 16 还补上了尺寸问题。UIHostingConfiguration 利用 UIKit 新的 self-resizing cells:SwiftUI cell 内容变化后,包含它的 UICollectionViewCellUITableViewCell 会在需要时自动调整尺寸,这个能力默认开启(21:44)。

最后,SwiftUI 也能把数据写回 model。session 用 MedicalCondition 举例:model 是 ObservableObjecttext@Published。SwiftUI view 可以对这个 published 属性创建双向 binding,用户编辑后直接写回 model(22:48)。

// Creating a two-way binding to data in SwiftUI

class MedicalCondition: Identifiable, ObservableObject {
    let id: UUID

    @Published var text: String
}

struct MedicalConditionView: View {
    @ObservedObject var condition: MedicalCondition

    var body: some View {
        HStack {

            Spacer()
        }
    }
}

关键点:

  • MedicalCondition: Identifiable 提供 diffable data source 可以使用的稳定身份。
  • ObservableObject 让 SwiftUI 可以观察这个 model。
  • @Published var text 是可被 SwiftUI 读取并刷新界面的属性。
  • @ObservedObject var condition 把 cell 对应的 model 接进 SwiftUI view。
  • 逐字稿说明,把只读 Text 改成 TextField,并给 condition.text$ 前缀,就能创建写回 model 的 binding(23:47)。

两种承载方式的边界

session 结尾给了一个容易踩坑的边界。UIHostingController 是 view controller,使用时要把 view controller 和它的 view 一起加入 UIKit 层级。工具栏、键盘快捷键、使用 UIViewControllerRepresentable 的 SwiftUI view,都依赖 UIKit view controller hierarchy(24:33)。

UIHostingConfiguration 则用于 cell,它不创建 UIViewController。它支持大多数 SwiftUI 功能,但依赖 UIViewControllerRepresentable 的 SwiftUI view 不能放在 cell 里(25:01)。

核心启发

  • 做什么:把一个复杂的 UIKit 自定义 cell 改成 UIHostingConfiguration为什么值得做:cell 内部布局可以拆成 SwiftUI 小视图,样式、图表和状态展示更容易组合。 怎么开始:保留现有 collection view 或 table view,只替换 cell 的 contentConfiguration

  • 做什么:把一个已有 UIKit 详情页中的局部卡片改成 SwiftUI。 为什么值得做UIHostingController 可以作为 child view controller 嵌入,不要求替换整个页面。 怎么开始:创建 SwiftUI card view,用 UIHostingController(rootView:) 承载,再通过 addChild 接入现有 view controller。

  • 做什么:让 UIKit model 的变化自动驱动 SwiftUI 局部界面刷新。 为什么值得做:手动改 rootView 容易遗漏,ObservableObject@Published 能把刷新绑定到数据变化。 怎么开始:把已有 model conform 到 ObservableObject,在 SwiftUI view 中用 @ObservedObject 保存它。

  • 做什么:给列表 cell 增加 SwiftUI swipe actions。 为什么值得做:列表交互继续由 UIKit 管理,行内动作可以用 SwiftUI modifier 写在内容附近。 怎么开始:在 UIHostingConfiguration 的 root SwiftUI view 上添加 .swipeActions,动作里使用 item 的稳定 ID。

  • 做什么:给可编辑 cell 建立双向数据流。 为什么值得做:用户在 SwiftUI TextField 中输入后,可以直接写回 UIKit 侧仍在使用的 model 对象。 怎么开始:让 model 的可编辑字段使用 @Published,在 SwiftUI 中通过 $condition.text 传给输入控件。


关联 Session

评论

GitHub Issues · utterances