🦋

SwiftUI: UIViewRepresentableでWKWebViewを包んで使う

2022/12/20に公開

UIViewRepresentableUIKitやそのほかコンポーネントをラップして使う場合、そのコンポーネントが状態を持たず、コンポーネントのもつメソッドを直接叩く必要がなければ、特に問題なく実装できる。しかし、その逆の場合は元々のUIKitなどの命令的なUIとSwiftUIの宣言的なUIの特性がぶつかって実装が難しい。

まず、あるプロパティの変化がそのコンポーネント関係のDelegateで捕捉できるのであれば、Coordinatorを使ってSwiftUIのView側に値の変化を伝播させることが可能。

Coordinatorの例
struct WrappedUITextField: UIViewRepresentable {
    typealias UIViewType = UITextField

    @Binding var text: String

    func makeUIView(context: Context) -> UITextField {
        let view = UITextField()
        view.delegate = context.coordinator
        return view
    }

    func updateUIView(_ uiView: UITextField, context: Context) {}

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    class Coordinator: NSObject, UITextFieldDelegate {
        let contentView: WrappedUITextField

        init(_ contentView: WrappedUITextField) {
            self.contentView = contentView
        }

        func textFieldDidEndEditing(_ textField: UITextField) {
            contentView.text = textField.text ?? ""
        }
    }
}

しかし、WKWebViewの場合、ヒストリーの「戻る」や「進む」を実装するのに.canGoBack.canGoForwardの値を監視したいが、WebKitのDelegateに良いものがないため、直接監視する必要がある。監視するにはwebView.publisher(for: \.canGoBack)のようにCombineを使えば良いが、これをUIViewRepresentableの実装の中でやると、Viewの更新中に値の更新をするなと警告が出る。

また、WKWebViewのメソッド(.goBack().goForward().load(request:).reload())を叩く場合も厄介。@Bindingしたフラグがtrueの時に発火、フラグを使ったらすぐにfalseにするという実装でも何とかなるが、明らかに余計なフラグが増えるし、フラグをfalseにすることで無駄に再描画の要求が走る。

フラグを使った例
struct WrappedWebView: UIViewRepresentable {
    typealias UIViewType = WKWebView

    @Binding var needsReload: Bool

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        if needsReload {
            webView.reload()
            needsReload = false // 書き換えることでもう一度updateUIViewが呼ばれる
        }
    }
}

そのため、もっとすっきりとWKWebViewUIViewRepresentableで包んで使う方法を考えた。

対策

WKWebViewを扱うViewのViewModelを用意し、そのViewModelに直接WKWebViewのインスタンスを持たせてプロパティの監視とメソッドの呼び出しをやらせる。つまり、UIViewRepresentableにはWebViewを表示する額縁としての役割だけを持たせ、WebViewの状態管理とアクションはViewModel側で管理する。

WKWebViewのUIViewRepresentable
import SwiftUI
import WebKit

struct WrappedWKWebView: UIViewRepresentable {
    typealias UIViewType = WKWebView

    let setWebViewHandler: (WKWebView) -> Void

    func makeUIView(context: Context) -> WKWebView {
        let webview = WKWebView()
        setWebViewHandler(webview)
        return webview
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {}
}

WKWebViewUIViewRepresentableは非常にシンプル。
setWebViewHandlerwebViewのインスタンスをViewModelに渡せるようにしておく。

ViewModel
import Foundation
import Combine
import WebKit

class WebViewModel: ObservableObject {
    @Published var canGoBack: Bool = false
    @Published var canGoForward: Bool = false

    private weak var webView: WKWebView?
    private var cancellables = Set<AnyCancellable>()

    func setWebView(_ webView: WKWebView) {
        self.webView = webView

        webView.publisher(for: \.canGoBack)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] value in
                self?.canGoBack = value
            }
            .store(in: &cancellables)

        webView.publisher(for: \.canGoForward)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] value in
                self?.canGoForward = value
            }
            .store(in: &cancellables)
    }

    func initialLoad() {
        if let url = URL(string: "https://www.google.com/?hl=ja") {
            webView?.load(URLRequest(url: url))
        }
    }

    func goBack() {
        if canGoBack {
            webView?.goBack()
        }
    }

    func goForward() {
        if canGoForward {
            webView?.goForward()
        }
    }

    func reload() {
        webView?.reload()
    }
}

ViewModelとして本来必要な機能はもっと大量にあるが省略している。setWebView()WKWebViewのプロパティの監視をしているところがポイント。あと地味にメモリリークを気にしてwebViewweakで持つようにしている。WKWebViewのアクション系メソッドは直接ViewModelから叩く。

View
import SwiftUI

struct WebView: View {
    @StateObject var viewModel: WebViewModel

    var body: some View {
        VStack {
            WrappedWKWebView { webView in
                viewModel.setWebView(webView)
            }
            HStack {
                Button("back") {
                    viewModel.goBack()
                }
                .disabled(!viewModel.canGoBack)
                Button("forward") {
                    viewModel.goForward()
                }
                .disabled(!viewModel.canGoForward)
                Button("reload") {
                    viewModel.reload()
                }
            }
        }
        .padding()
        .onAppear {
            viewModel.initialLoad()
        }
    }
}

ViewはWKWebViewとViewModelの橋渡しになるようにする。setWebViewしているところを除けばかなりSwiftUIライクな(宣言的UI)書き方ができていると思う。

具体的なソース

https://github.com/kyome22/MinBrowser

ちなみに

SwiftUIでWKWebViewを扱う時はちゃんとしないと挙動不審になる。


挙動不審な様子

https://zenn.dev/kyome/articles/0e7ec77d73167b

Discussion