本投稿は TECOTEC Advent Calendar 2023 の 12 日目の記事です。
はじめに
こんにちは。決済認証システム開発事業部 の 牛越 嵩 です。10月頭に、iOS エンジニアとして入社しました。 (入社前までは React 中心にやっておりました。初の iOS 実務です。)
さて、最近、WKWebView を使う機会があり、UIViewRepresentable を使用して SwiftUI で使えるようにしたのですが、その際に得た知見を紹介しようと思います。
ただ、UIViewRepresentable
でラップしただけの話だと二番煎じになってしまい、面白みがないので、SwiftUI では難しそうな WKWebView.reload()
の実行等のやり方を紹介したいと思います。
(前提) SwiftUI で UIViewRepresentable を使用してコンポーネントを作成する際に心がけていること
大前提として、コンポーネントを作成する際には、そのコンポーネントの陰に何が隠れているか、というのをコンポーネント使用側に意識させない作り方が重要だと僕は考えています。
(準備) WebView コンポーネントの作成
以下のように作成します。
import SwiftUI import WebKit struct WebView: UIViewRepresentable { private var url: URL private let configuration: WKWebViewConfiguration? init(url: URL, configuration: WKWebViewConfiguration? = nil) { self.url = url self.configuration = configuration } func makeUIView(context: Context) -> WKWebView { let webView: WKWebView = { guard let configuration else { return .init() } return .init(frame: .zero, configuration: configuration) }() let request: URLRequest = .init(url: url) webView.load(request) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { } }
ScrollViewReader のような書き方で WebView の操作ができるようにする
UIViewRepresentable でラップした WKWebView は、SwiftUI が 子 View のメソッドを直接実行出来ないという特性上、直接 relaod をかけてやることができないと思います。
SwiftUI には ScrollViewReader という、子コンポーネントに存在する ScrollView の表示位置を制御できる特殊な View があります。
ScrollViewReader の例
ボタンタップ時に、特定の要素にスクロールする
import SwiftUI struct ContentView: View { var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack { ForEach(1..<100, id: \.self) { item in Text("Number" + String(item)) .id(item) } } } .overlay(alignment: .bottom) { HStack { Button( action: { proxy.scrollTo(1) } ) { Text("トップへ") } .buttonBorderShape(.roundedRectangle) .buttonStyle(.borderedProminent) } .padding(20) } } } }
これと同じようなことを、WKWebView をラップしたビューでもできたら楽ですよね。
struct ContentView: View { var body: some View { WebViewReader { proxy in WebView(url: URL) .overlay(alignment: .bottom) { Button( action: { proxy.reload() } ) { Text("再読み込み") } } } } }
↑こんなことができたら最高なのに...
すごく強引で愚直なやり方ですが、@EnvironmentObject.Optional
の力を借りることで実現できます。
まずざっくりと、完成形のコードを以下に書きます。
import SwiftUI import WebKit final class WebViewInstanceStore: ObservableObject { @Published var webView: WKWebView = .init() } @dynamicMemberLookup struct WebViewReaderProxy { @ObservedObject private var webViewInstanceStore: WebViewInstanceStore init(webViewInstanceStore: WebViewInstanceStore) { _webViewInstanceStore = .init(initialValue: webViewInstanceStore) } subscript<T>(dynamicMember keyPath: KeyPath<WKWebView, T>) -> T { webViewInstanceStore.webView[keyPath: keyPath] } @discardableResult public func goBack() -> WKNavigation? { return webViewInstanceStore.webView.goBack() } @discardableResult public func goForward() -> WKNavigation? { return webViewInstanceStore.webView.goForward() } @discardableResult public func reload() -> WKNavigation? { return webViewInstanceStore.webView.reload() } @discardableResult public func stopLoading() { return webViewInstanceStore.webView.stopLoading() } } struct WebViewReader<Content: View>: View { @StateObject private var webViewInstanceStore: WebViewInstanceStore = .init() @ViewBuilder var content: (WebViewReaderProxy) -> Content private var proxy: WebViewReaderProxy { .init(webViewInstanceStore: webViewInstanceStore) } init(@ViewBuilder content: @escaping (WebViewReaderProxy) -> Content) { self.content = content } var body: some View { content(proxy) .environmentObject(webViewInstanceStore) } }
そして、WebView
はこのように変更します。
struct WebView: UIViewRepresentable { @EnvironmentObject.Optional private var webViewInstanceStore: WebViewInstanceStore? private var url: URL private let configuration: WKWebViewConfiguration? init(url: URL, configuration: WKWebViewConfiguration? = nil) { self.url = url self.configuration = configuration } func makeUIView(context: Context) -> WKWebView { let webView: WKWebView = { guard let configuration else { return .init() } return .init(frame: .zero, configuration: configuration) }() if webViewInstanceStore != nil { webViewInstanceStore?.webView = webView } let request: URLRequest = .init(url: url) webView.load(request) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } static public func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.removeWKWebViewStateObserver(from: uiView) } class Coordinator: NSObject { private let parent: WebView private var updatingWebViewInstanceStoreTask: Task<Void, Never>? init(parent: WebView) { self.parent = parent updatingWebViewInstanceStoreTask = nil } deinit { updatingWebViewInstanceStoreTask?.cancel() } override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { guard let webView = object as? WKWebView else { return } if self.parent.webViewInstanceStore == nil { return } self.updatingWebViewInstanceStoreTask?.cancel() self.updatingWebViewInstanceStoreTask = Task { // MARK: @Published なプロパティを変更するので、メインスレッドで変更 await MainActor.run { self.parent.webViewInstanceStore!.webView = webView } } } func addWKWebViewStateObserver(to webView: WKWebView) { webView.addObserver(self, forKeyPath: #keyPath(WKWebView.isLoading), options: .new, context: nil) webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack), options: .new, context: nil) webView.addObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward), options: .new, context: nil) webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil) } func removeWKWebViewStateObserver(from webView: WKWebView) { if self.parent.webViewInstanceStore == nil { return } webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.isLoading), context: nil) webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoBack), context: nil) webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.canGoForward), context: nil) webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), context: nil) } } }
考え方としては、
WebView
内で使用されているWKWebView
のインスタンスを保存するオブジェクトWebViewInstanceStore
を作成ScrollViewProxy
のような構造体を作成@dynamicMemberLookup
を付与したのは、WKWebView
の各プロパティにアクセスしやすくするためです。
WebViewInstanceStore
を@StateObject
として持ち、クロージャでView
を受け取るWebViewReader
の作成- ここで、
WebViewReader.body
にクロージャを配置し、そのクロージャにenvironmentObject
モディファイアでWebViewInstanceStore
のインスタンスを渡します
- ここで、
WebViewReader
を使わずにWebView
を配置すると、EnvironmentObject
が渡されていない状態になるので、クラッシュしてしまいます。ここで活躍するのが SwiftUIX の@EnvironmentObject.Optional
です。Optional とすることで、クラッシュしなくなります。WKWebView.isLoading
や、WKWebView.canGoBack
等のプロパティの変更を使用側で反映されるようにするため、NSObject
に準拠したCoordinator
を作成し、WKWebView
で UI に関わりそうなプロパティを追跡する
といった感じになっております。
できあがったもの
import SwiftUI import WebKitUI struct ContentView: View { var body: some View { WebViewReader { proxy in WebView(url: URL(string: "https://tecotec.co.jp")!) .overlay(alignment: .bottom) { VStack { Spacer() Button( action: { proxy.reload() } ) { Text("再読み込み") .foregroundColor(.white) .padding() .background( RoundedRectangle( cornerRadius: 12 ) ) } .accentColor(.blue) } .padding(12) } } } }
無事 Web ページの再読み込みがかかりました!
使用側が WKWebView
の存在を意識しなくても良くなったので、書きやすくなったと思います。
おわりに
めちゃくちゃオチのない内容になってしまいましたが、いかがだったでしょうか。
WKWebView
を SwiftUI で使う際に少しでも参考になれば幸いです!
おまけ
Pull to Refresh の実装
こんな感じで書けたらいいですよね。
WebView(url: ...) // 画面を下に引っ張ると WebView が再読み込みされる .refreshable { ... }
ということで、できあがったコードがこちらです。
import SwiftUI import WebKit struct WebView: UIViewRepresentable { private var url: URL private let configuration: WKWebViewConfiguration? private var onRefreshCallback: ((_ webView: WKWebView) async -> Void)? init(url: URL, configuration: WKWebViewConfiguration? = nil) { self.url = url self.configuration = configuration } func makeUIView(context: Context) -> WKWebView { let webView: WKWebView = { guard let configuration else { return .init() } return .init(frame: .zero, configuration: configuration) }() if self.onRefreshCallback != nil { webView.scrollView.refreshControll = context.coordinator.refreshControll(for: webView) } let request: URLRequest = .init(url: url) webView.load(request) return webView } func updateUIView(_ uiView: WKWebView, context: Context) { } class Coordinator { private let parent: WebView private var refreshableTask: Task<Void, Never>? init(parent: WebView) { self.parent = parent } deinit { refreshableTask?.cancel() } func refreshControl(for webView: WKWebView) -> UIRefreshControl { let refreshControl: UIRefreshControl = .init() let action: UIAction = .init { [weak self] _ in guard let unwrappedSelf = self else { return } unwrappedSelf.refreshControlTask?.cancel() unwrappedSelf.refreshControlTask = Task { await unwrappedSelf.parent.onRefreshCallback?(webView) await MainActor.run { // MARK: 呼ばないと永遠にクルクルする refreshControl.endRefreshing() } } } refreshControl.addAction( action, for: .valueChanged ) return refreshControl } } } extension WebView { // MARK: モディファイアっぽく使えるようにするやつ func refreshable(action: @escaping @Sendable (_ webView: WKWebView) async -> Void) -> Self { var view = self view.onRefreshCallback = action return view } func refreshable() -> Self { var view = self view.refreshableCallback = { if $0.url == nil, let url = view.url { $0.load(URLRequest(url: url)) return } $0.reload() } return view } }
実際に使うときのコードがこちらです。
WebView(url: URL(string: "https://tecotec.co.jp")!) .refreshable()
テコテックの採用活動について
テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。
tecotec.co.jp