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

总结