SwiftUI: UIViewRepresentableでWKWebViewを包んで使う
UIViewRepresentable
でUIKit
やそのほかコンポーネントをラップして使う場合、そのコンポーネントが状態を持たず、コンポーネントのもつメソッドを直接叩く必要がなければ、特に問題なく実装できる。しかし、その逆の場合は元々のUIKitなどの命令的なUIとSwiftUIの宣言的なUIの特性がぶつかって実装が難しい。
まず、あるプロパティの変化がそのコンポーネント関係のDelegateで捕捉できるのであれば、Coordinator
を使ってSwiftUIのView側に値の変化を伝播させることが可能。
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が呼ばれる
}
}
}
そのため、もっとすっきりとWKWebView
をUIViewRepresentable
で包んで使う方法を考えた。
対策
WKWebView
を扱うViewのViewModelを用意し、そのViewModelに直接WKWebView
のインスタンスを持たせてプロパティの監視とメソッドの呼び出しをやらせる。つまり、UIViewRepresentable
にはWebViewを表示する額縁としての役割だけを持たせ、WebViewの状態管理とアクションはViewModel側で管理する。
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) {}
}
WKWebView
のUIViewRepresentable
は非常にシンプル。
setWebViewHandler
でwebView
のインスタンスを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
のプロパティの監視をしているところがポイント。あと地味にメモリリークを気にしてwebView
はweak
で持つようにしている。WKWebView
のアクション系メソッドは直接ViewModelから叩く。
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)書き方ができていると思う。
具体的なソース
ちなみに
SwiftUIでWKWebViewを扱う時はちゃんとしないと挙動不審になる。
挙動不審な様子
Discussion