WWDC 2019 - Integrating SwiftUI

SwiftUI 作为今年 WWDC 的重头戏,惊艳之余我们还需要关注一下它是如何与现有的 UIKit / AppKit / WatchKit 进行交互,以便我们能够在将来更平滑无缝地接入到已有的代码里。

这个 Session 的内容偏向于 API 的介绍,主要内容如下:

  • 与原生框架的交互
    • 原生页面里嵌入 SwiftUI
    • SwiftUI 里嵌入原生页面
  • 集成已有的数据模型
  • 集成已有的系统功能
    • Drag & Drop
    • 复制粘贴
    • 焦点
    • Command
    • Undo & Redo
  • 总结

此外,这次苹果还推出了一系列的 SwiftUI 教程,其中一节的内容讲的就是与 UIKit 的交互,推荐与本文一同阅读。

与原生框架的交互

与原生框架的交互主要是原生与 SwiftUI 页面的相互嵌套,由于 SwiftUI 的数据流设计,所以 SwiftUI 里嵌套原生页面涉及的 API 会比较多一些。

原生页面里嵌入 SwiftUI

将 SwiftUI 嵌入到 ViewController 里只需要套一层 HostingController 就可以了,以 UIKit 为例使用的就是 UIHostingController,它是 UIViewController 的子类,初始化时传入 SwiftUI 的 View 即可:

class UIHostingController: UIViewController {
init(rootView: View) { ... }
}

需要注意的是 WatchKit 里的 WKHostingController 略微有些不同,需要通过继承去完成这一个过程。

class WKHostingController<Body: View>: WKInterfaceController 

SwiftUI 里嵌入原生页面

将 UIKit / AppKit / WatchKit 的 View / ViewController 嵌入到 SwiftUI 中则需要使用一套 Representable 协议。

UIView 为例,与它对应的是 UIViewRepresentable,里面包含了四个生命周期方法:

  1. func makeCoordinator() -> Coordinator(可选):在 View 创建前调用,用于创建 Coordinator。
  2. func makeUIView(context:) -> UIView(必要):在 View 创建时会被调用一次。
  3. func updateUIView(_:context:)(必要):在 View 创建后会被立刻调用一次,随着数据更新会被反复调用。
  4. func dismantleUIView(_:coordinator:)(可选):会在 View 被移除时调用。
协议创建更新销毁
UIView
Representable
makeUIView
(context:)
updateUIView
(_:context:)
dismantleUIView
(_:coordinator:)
UIViewController
Representable
makeUIViewController
(context:)
updateUIViewController
(_:context:)
dismantleUIViewController
(_:coordinator:)
NSView
Representable
makeNSView
(context:)
updateNSView
(_:context:)
dismantleNSView
(_:coordinator:)
NSViewController
Representable
makeNSViewController
(context:)
updateNSViewController
(_:context:)
dismantleNSViewController
(_:coordinator:)
WKInterfaceObject
Representable
makeWKInterfaceObject
(context:)
updateWKInterfaceObject
(_:context:)
dismantleWKInterfaceObject
(_:coordinator:)

那么我们该如何使用这一套 API 去完成常用的几个功能:

  • Target-Action / delegate 代理
  • 响应 Environment 的变化
  • 使用 SwiftUI 进行动画

为了让 SwiftUI 与原生的 View 更好地交互,SwiftUI 提供了一个 RepresentableContext 协议,它包含了三个属性:

  • Coordinator:帮助协调原生 View 与 SwiftUI,实现 Target-Action 和 delegate 模式。
  • Environment:帮助原生 View 读取 SwiftUI 的 Environment,提供布局方向和 size-class 等等。
  • Transaction:让原生 View 获取到 SwiftUI 传入的动画属性。

在这里我们通过一个简单的例子,将 UIKit 的 UISlider 封装到 SwiftUI 里:

struct UIKitSlider: UIViewRepresentable {
@Binding var value: Int

func makeUIView(context: Context) -> UISlider {
let control = UISlider()
return control
}

func updateView(_ uiView: UISlider, context: Context) {
uiView.value = value
}
}

使用 Target-Action 的时候,苹果建议使用 Coordinator 来完成 View 与数据的交互,首先我们建立一个 Coordinator 对象来记录 value 的改变:

extension UIKitSlider {
class Coordinator: NSObject {
@Binding var value: Float

init(value: Binding<Float>) {
self.$value =value
}

@objc func valueChanged(_ sender: UISlider) {
self.value = sender.value
}
}
}

为了保持数据的一致性,SwiftUI 推荐使用 Binding 类型来表达派生值(Derived Value),所以这里构造器传入的是 Binding<Float>,这里不做过多解释,具体的内容请看 Session 226 - SwiftUI 里的数据流

最后我们在 makeCoordinator 方法里创建 Coordinator,在 makeUIView 方法里通过 context 获取到 Coordinator 进行 Target-Action 的绑定:

struct UIKitSlider: UIViewRepresentable {
...

func makeCoordinator() -> Coordinator {
return Coordinator(value: $value)
}

func makeUIView(context: Context) -> UISlider {
let slider = UISlider()
slider.addTarget(
context.coordinator,
action: #seletor(Coordinator.ratingChanged),
for: .valueChanged
)
return slider
}
}

集成数据模型

SwiftUI 内部的数据流管理非常直观易用,但我们也需要接入数据库等外部数据,这时候我们需要某种机制来让它们绑定到一起。

SwiftUI 提供了一个 BindableObject 协议来实现这部分功能,协议的实现要求非常简单,只有一个必须实现的 didChange 属性,在每次数据产生变动后让 didChange 发出一个信号即可:

class DataModel: BindableObject {
var didChange = PassthroughSubject<Void, Never>()

var userData: UserData {
didSet {
didChange.send()
}
}
}

另外,在 View 里使用 BindableObject 的时候需要使用 @ObjectBinding 修饰:

struct ArticleList: View {
@ObjectBinding var data: DataModel

var body: some View { ... }
}

这样 SwiftUI 才能知道哪些数据是跟 View 绑定到一起的,在这些数据更新时让 View 也保持同步:

Screen Shot 2019-07-01 at 11.32.34

系统集成

除了 UI 和数据之外,我们还需要与系统进行交互,SwiftUI 在这方面的 API 非常完备,之前系统里包含的功能都可以在 SwiftUI 里找到。

Drag & Drop

extension View {
func onDrag(
_ data: @escaping () -> NSItemProvider
) -> some View

func onDrop(
of supportedTypes: [String],
delegate: DropDelegate
) -> some View

func onDrop(
of supportedTypes: [String],
isTargeted: Binding<Bool>?,
perform action: @escaping ([NSItemProvider], CGPoint) -> Bool
) -> some View
}

复制粘贴

extension View {
func onPaste(
of supportedTypes: [String],
perform action: @escaping ([NSItemProvider]) -> Void
) -> some View

func onPaste<Payload>(
of supportedTypes: [String],
validator: @escaping ([NSItemProvider]) -> Payload?,
perform action: @escaping (Payload) -> Void
) -> some View
}

焦点

粘贴的操作与拖拽有一个区别,就是粘贴操作需要了解当前的焦点,把事件分发给当前焦点所在的 View 进行处理。

查找响应者的过程类似于 UIKit 里的 ResponderChain,从焦点所在的 View 向上查找响应事件的 View:

Screen Shot 2019-06-22 at 11.39.32

SwiftUI 里大部分 View 默认都是无法成为焦点的,想要响应焦点事件的话,可以使用 focusable 这个 Modifier:

extension View {
func focusable(
_ isFocusable: Bool,
onFocusChange: @escaping (Bool) -> Void = { _ in }
) -> some View
}

Command

onCommand 是比较特殊的函数,它可以用来接收响应链上抛出的任何事件,例如菜单或者是 ToolBar 上的用户操作:

extension View {
func onCommand(
_ command: Command,
perform action: (() -> Void)?
) -> some View
}

Command(#selector(Object.someOperation))

除此之外还有 onCommand / onExit / onPlayPause 等等,这些基本的系统交互功能在 SwiftUI 上 都有对应的 API 能够使用。

Undo & Redo

在 SwiftUI 里使用 UndoManager 跟以往一样,直接在数据层进行交互即可,但如果你需要在 View 里获取到当前的 UndoManager,你只需要从 Environment 里获取即可:

@Environment(\EnvironmentValues.undoManager) var undoManager

总结