SwiftUI中使用"UIViewRepresentable"桥接 UIKit View

SwiftUI带来了构建界面的全新范式,但是至今依然不能支持 UIView里的所有 UIControls,像是 UIActivityIndicatorView, WKWebView, MKMapView 以及 UIPageControl等都没有 SwiftUI 原生的支持。

但是好在我们可以通过 UIViewRepresentable 协议将 UIView 包装后用在 SwiftUI里,同时也有 UIViewControllerRepresentable 协议来将 UIViewController 集成到 SwiftUI中。

本文目标:

理解 UIViewRepresentable 是如何工作的并探究它的生命周期 使用 Coordinator 在 SwiftUI 与 UIKit 之间传递数据;通过将 UISearchBar 集成到 SwiftUI 进行说明 创建泛型包装器来快速集成 任意 UIView 到 SwiftUI 界面中

UIViewRepresentable 协议

通过该协议就可以让我们轻松地在 SwiftUI 界面上使用 UIView
这个协议有两个必须提供的方法:makeUIViewupdateUIView

下面是一个如何包装 UIActivityIndicatorView 的例子:

struct ActivityIndicator: UIViewRepresentable {
    
    @Binding var startAnimating: Bool
    
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        return UIActivityIndicatorView()
    }

    func updateUIView(_ uiView: UIActivityIndicatorView,
                      context: Context) {
        if self.startAnimating {
            uiView.startAnimating()
        } else {
            uiView.stopAnimating()
        }
    }
}
makeUIView 方法创建了一个要表示的 UIView, 在其生命周期里只会调用一次 updateUIView 方法会在 UIView 的状态发生变化时被调用,所以在其生命周期内会被调用多次(即使这是一个空实现也会被调用多次)。这里我们通过一个 @Binding 属性来控制这个活动指示器是否显示。

下面是一个应用该活动指示器的简单 SwiftUI程序,用一个按钮来控制是否显示:
image

通过这个 @Binding 包装属性, 我们将一个 SwiftUI state 与 ActivityIndicator 绑定在一起,当这个 state 变化,就会触发 updateUIView 方法,活动指示器就会跟着显示或者消失

使用 Coordinator

@Binding 包装属性可以让我们将数据从 SwiftUI 传递给 UIKit View,那反过来当 SwiftUI 要从 UIKit View 中获取数据,要怎么做呢?

这时候就该 Coordinator 登场了,它是一个用来实现 UIKit View 的代理的类,可以在其中实现诸如在 MKMapView 上添加 annotations,或者更新 UIPageController 的 current index 等等

下面是一个实现了 UISearchBarDelegateCoordinator

class Coordinator: NSObject, UISearchBarDelegate {

    @Binding var text: String

    init(text: Binding<String>) {
        _text = text
    }
    
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        text = searchText
    }
}

接下来,给实现了 UIViewRepresentable 的结构体添加 makeCoordinator 方法,该方法会在 makeUIView 之前调用,会为 context 创建 coordinator,这个 context 保存的是 UIViewRepresentable view 的当前状态,当需要创建(makeUIView)和更新(updateUIView)这个 view时 就会把 context 作为参数传递进去;所以我们就可以在 makeUIView 中将 context 中的 coordinator 赋给 UIView 的 delegate,代码如下:

struct SearchBarView: UIViewRepresentable {

    @Binding var text: String
    var placeholder: String

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text)
    }
    
    func makeUIView(context: Context) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        searchBar.placeholder = placeholder
        searchBar.searchBarStyle = .minimal
        searchBar.autocapitalizationType = .none
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar,
                      context: Context) {
        uiView.text = text
    }
}

在上面的代码中,一旦 UISearchBarDelegate 代理触发,coordinator 更新 text,也就触发了 updateUIView,也就最终更新了 UISearchBar,下面是相应的 SwiftUI App 展示:

image

UIViewRepresentable 生命周期

image

dismantleUIView 相当于 UIView 的 deinit 方法,可以在其中做一些诸如删除通知 observer,停止 timer 等清理工作

UIViewControllerRepresentable 的生命周期也差不多,只是把相应的方法做一个替换即可

泛型 UIViewRepresentable

上面我们包装了 UIActivityIndicatorUISearchBar,每次都要把 makeUIViewupdateUIView 这一套写一遍。

一方面如果我们项目里用的 UIKit View 比较多,每次都写一遍有点烦,另一方面这里有一个视图逻辑分离的问题。举例来说,UIActivityIndicator 是在 SwiftUI 里创建的,但是它动画开关逻辑却被放在了 UIViewRepresentable 里。

好在我们可以通过创建一个泛型的 UIViewRepresentable 结构体来包装任意 UIKit View,代码如下:

struct Anything<Wrapper : UIView>: UIViewRepresentable {

    var makeView: () -> Wrapper
    var update: (Wrapper, Context) -> Void

    init(_ makeView: @escaping @autoclosure () -> Wrapper,
         updater update: @escaping (Wrapper) -> Void) {
        self.makeView = makeView
        self.update = { view, _ in update(view) }
    }

    func makeUIView(context: Context) -> Wrapper {
        makeView()
    }

    func updateUIView(_ view: Wrapper, context: Context) {
        update(view, context)
    }
}

@autoclosure 不是必须的,但是它可以让方法调用看上去更加舒服,因为第一个参数不需要加闭包的括号了,直接用 UIView 的表达式即可;此外,自动闭包有延迟执行的特性,只有在需要的时候才会去创建 UIView。

下面是用 Anything 来包装使用 UIActivityIndicatorView 的代码:

Anything(UIActivityIndicatorView(style: .large)) {
    if shouldAnimate {
        $0.startAnimating()
    } else {
        $0.stopAnimating()
    }
}

使用 Anything, 我们可以包装任意 UIKit View,如果需要特定的 Coordinator,我们可以扩展上面的泛型代码来创建对应的代理方法