WKWebView.reload() を SwiftUI でもできるようにする

本投稿は 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)
        }
    }
}

考え方としては、

  1. WebView 内で使用されている WKWebView のインスタンスを保存するオブジェクト WebViewInstanceStore を作成
  2. ScrollViewProxy のような構造体を作成
    • @dynamicMemberLookup を付与したのは、WKWebView の各プロパティにアクセスしやすくするためです。
  3. WebViewInstanceStore@StateObject として持ち、クロージャで View を受け取る WebViewReader の作成
    • ここで、WebViewReader.body にクロージャを配置し、そのクロージャに environmentObject モディファイアで WebViewInstanceStore のインスタンスを渡します
  4. WebViewReader を使わずに WebView を配置すると、EnvironmentObject が渡されていない状態になるので、クラッシュしてしまいます。ここで活躍するのが SwiftUIX の @EnvironmentObject.Optional です。Optional とすることで、クラッシュしなくなります。
  5. 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)
                }
        }
    }
}

WebView の再読み込み

無事 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