WKWebViewをSwiftUIに適したインターフェースで扱えるラッパーライブラリをOSSとして公開しました!

はじめに

こんにちは、iOSエンジニアのKyome(@Kyomesuke)です。

私が担当しているkintoneのモバイルアプリはWKWebViewを用いたハイブリッドアプリなのですが、UIKitからSwiftUIへのリプレイスにあたってWebViewをSwiftUIに適したインターフェースで(つまり宣言的UIで)実装したいという需要が高まっていました。 しかし現在、SwiftUIと互換性のある宣言的UIなWebViewのAPIはAppleから提供されていません。

そこで、WKWebViewをSwiftUIに適したインターフェースで扱えるようにしたラッパーライブラリを開発しました! この記事では、開発したライブラリであるWebUIの使い方と工夫した点を紹介します。

github.com

WebUIの使い方

WebUIはSwift Package Managerで導入できます。 具体的な導入方法はREADMEを参照してください。

動作条件

  • iOS 16.4 以上
  • macOS 13.3 以上
  • Xcode 15.1 以上

任意の1ページを表示するだけの場合

WebViewというViewを使います。1
このWebViewのイニシャライザからロードするページをURLRequestで渡すことができます。

import SwiftUI
import WebUI

struct ContentView: View {
    var body: some View {
        WebView(request: URLRequest(url: URL(string: "https://example.com/")!))
    }
}

ページの表示に加えて操作も行いたい場合

WebViewに加えてWebViewReaderを使います。 WebViewReaderのスコープ内ではWebViewProxyを受け取ることができ、これを介してWebViewReaderスコープ内にあるWebViewを操作することが可能です。 (WebViewReaderの中ではWebViewを一度だけ使うことを推奨しており、複数使用した場合の動作は保証していません。) 例えば、WebViewProxy.load(request:)WebViewProxy.goBack()を用いることで、それぞれページのロードや前のページに戻る操作ができます。

また、WebViewProxyにはtitleisLoadingcanGoBackなどWebViewの状態を取得できるプロパティも用意されています。 これらを用いて宣言的にWebViewを扱うことができます。

import SwiftUI
import WebUI

struct ContentView: View {
    var body: some View {
        WebViewReader { proxy in
            WebView()
                .onAppear {
                    proxy.load(request: URLRequest(url: URL(string: "https://example.com/")!))
                }

            HStack {
                Button("Go Back") {
                    proxy.goBack()
                }
                .disabled(proxy.canGoBack)

                Button("Go Forward") {
                    proxy.goForward()
                }
                .disabled(proxy.canGoForward)

                Spacer()

                Button("Reload") {
                    proxy.reload()
                }
            }
        }
    }
}

さらにカスタマイズしたい場合

まず、WebViewのイニシャライザからWKWebViewConfigurationを渡すことが可能です。 これによりWebViewの仕様を調整することができます。

import SwiftUI
import WebKit
import WebUI

struct ContentView: View {
    let configuration: WKWebViewConfiguration

    init() {
        configuration = .init()
        configuration.allowsInlineMediaPlayback = true
    }

    var body: some View {
        WebView(configuration: configuration)
    }
}

次に、WKUIDelegateWKNavigationDelegateに準拠したクラスを設定するためのModifierが用意されています。 前者を用いてJavaScriptのアラートが呼び出された時の処理をネイティブ側でハンドリングしたり、後者を用いてWebViewのロード前後で任意の処理を挟み込んだりできます。

import SwiftUI
import WebKit
import WebUI

final class MyUIDelegate: NSObject, WKUIDelegate {
    // 中略
}

final class MyNavigationDelegate: NSObject, WKNavigationDelegate {
    // 中略
}

struct ContentView: View {
    var body: some View {
        WebView()
            .uiDelegate(MyUIDelegate())
            .navigationDelegate(MyNavigationDelegate())
    }
}

他にもWebViewを便利にカスタマイズするModifierを用意してあります。

  • SafariのWebインスペクタでのデバッグ可否を制御するallowsInspectable(_:)
  • 端末のエッジスワイプによるヒストリー操作の可否を制御するallowsBackForwardNavigationGestures(_:)
  • ページ内のリンクプレビューの表示可否を制御するallowsLinkPreview(_:)
  • 画面を下に引っ張ってリロードを促す(いわゆるPull-to-Refresh)ことを可能にするrefreshable()
import SwiftUI
import WebUI

struct ContentView: View {
    var body: some View {
        WebView()
            .allowsInspectable(true)
            .allowsBackForwardNavigationGestures(true)
            .allowsLinkPreview(true)
            .refreshable()
    }
}


WebUIの使い方は以上です。

publicなAPIに関してはSwift-DocCを用いてドキュメントを書いています。 また、ドキュメントページがGitHub Pagesにデプロイしてありますので、WebUIの使い方について詳しく参照したい場合はこちらを活用してください。

cybozu.github.io

工夫した点

SwiftUIに適したインターフェース設計

こちらは今回の最大の目的であるためかなり慎重に設計しました。 ただ単純にWKWebViewUIViewRepresentableでラップしてSwiftUIのコンテキストで使えるようにするだけでなく、宣言的UIとして不整合なく書けるようにすること、SwiftUI既存のAPIと同様なインターフェースで自然に書けるようにすることなどを意識しました。 この設計ではScrollViewScrollViewReaderGeometryReaderTextView.onDrop(of:delegate:)など既存のAPIを参考にしました。

まず、ScrollViewは単体でも使えるようになっています。 単体で使う場合はスクロールの方向とインジケーターの棒を表示するかどうかの指定ができますが、ユーザーの操作以外にプログラムから能動的にScrollViewの操作を行うことはできません。

struct ContentView: View {
    var body: some View {
        ScrollView(.vertical, showsIndicators: false) {
            ForEach(0 ..< 100, id: \.self) { i in
                Text("\(i)")
            }
        }
    }
}

そこで、ScrollViewReaderを用いてこのスコープ内にScrollViewを配置することで、ScrollViewProxyを介してScrollViewの操作をプログラムから能動的に行うことができるようになります。

struct ContentView: View {
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView(.vertical, showsIndicators: false) {
                ForEach(0 ..< 100, id: \.self) { i in
                    Text("\(i)")
                        .id("text-\(i)")
                }
            }
            Button("Show 50") {
                proxy.scrollTo("text-50")
            }
        }
    }
}


続いて、GeometryReaderを利用するとそのスコープ内のViewの位置やサイズの情報をGeometryProxyを経由して取得することができます。

struct ContentView: View {
    var body: some View {
        GeometryReader { proxy in
            VStack {
                Text("width: \(proxy.size.width)")
                Text("height: \(proxy.size.height)")
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}


Textにはfont(_:)fontWeight(_:)fontDesign(_:)などのModifierが生えています。 SwiftUIのAPIの中では比較的珍しくModifierを適用した結果Viewではなく元の型Textを返します。

struct ContentView: View {
    var body: some View {
        Text("Hello World")
            .fontWeight(.bold)
            .fontDesign(.monospaced) // ここまではText
            .background(Color.gray) // ここでViewになる
    }
}


SwiftUIでDelegateに処理を委譲するようなインターフェースを提供しているものはそれほどないのですが、View.onDrop(of:delegate:)が参考になります。 (名前こそDelegateではありませんが、View.gesture(_:)もDelegateパターンかもしれません。) 特定のViewに対してModifierを用いてDelegateを紐づけることができます。

struct MyDropDelegate: DropDelegate {
    func performDrop(info: DropInfo) -> Bool {
        // 中略
        return true
    }
}

struct ContentView: View {
    var body: some View {
        Text("Drop Item")
            .onDrop(of: [.image], delegate: MyDropDelegate())
            .gesture(LongPressGesture())
    }
}


これらの既存のAPIのインターフェースを参考にして、WebUIでも同様にWebViewWebViewReaderWebViewProxyを提供することにしました。

  • 任意のページを表示するだけの場合、WebViewのみを用いて実装可能にする
  • WKWebView固有の挙動設定(例えばisInspectableallowsLinkPreview)に関しては、WebViewに直接生えたModifierを介して設定できる
    • Modifierを適用したViewの型はWebViewのままである
  • WebViewReaderのスコープでWebViewを囲うことでWebViewProxyを介してload(_:)goBack()のようなWKWebViewの操作が行える
  • WebViewReaderのスコープでWebViewを囲うことでWebViewProxyを介してisLoadingcanGoBackのようなWKWebViewの状態を取得できる

最終的に上記のような方針でインターフェースを提供することにしました。

SwiftUIに適したインターフェース実装

上記で最高なインターフェースの設計はできましたが、その実装にも工夫が必要でした。

UIViewRepresentable.updateUIView(_:context:)の無限ループ対策

WKWebViewをSwiftUIのViewで提供するにはUIViewRepresentableを用いるのですが、不注意に実装していくと無限ループの不具合を容易に起こします。 WKWebViewの持つ状態(isLoadingcanGoBackなど)を別のViewで扱えるようにObservableObjectに準拠したクラスや@Observableマクロを適用したクラスなどに同期させることを考えます。 この時、WKWebViewの操作(load(_:)reload()goBack()など)を行うためにUIViewRepresentableと先述のクラスを紐づけると、その実装の仕方次第ではWKWebViewの状態更新とそれによるViewの再描画が無限に連鎖してしまいます。

UIViewRepresentableの無限ループの図
UIViewRepresentableの無限ループの図

より具体的には

  1. UIViewRepresentableを介してWKWebViewに対してロードのリクエストを送る
  2. WKWebViewisLoadingが更新される
  3. isLoadingの値の更新がObservableObjectに準拠したクラスに伝搬される
  4. ObservableObjectobjectWillChangeが発火される
  5. objectWillChangeによりUIViewRepresentable.updateUIView(_:context:)が発火される
  6. WKWebViewに対してしてロードのリクエストが再び送られる←2へ戻り、以降無限ループ

のような無限ループに陥ります。

この問題を回避するためには、状態としてのWKWebViewUIViewRepresentableの外に逃すこと、WKWebViewの状態が更新された際にそれがUIViewRepresentableに伝播しないようにすることの2つが肝要です。 WebUIではWKWebViewUIViewRepresentableの外に持ち出すための仕組みをEnvironmentValuesを用いて実現しています。 具体的には以下のソースコードを参照してください。

Pull to Refreshの実装

Pull to Refreshといえば、画面を下にスクロールして限界を超えて引っ張りコンテンツの更新を促す操作ですが、WebUIのWebViewでは標準でこの機能を提供するModifierを提供しています。 ただ、この機能の実装において厄介なポイントがいくつかありました。

WKWebViewの持つUIScrollViewUIRefreshControlをセットすることで実装していくのですが、UIRefreshControlは任意の更新処理が完了した際にendRefreshing()というメソッドを叩く必要があります。 今回はPull to Refreshの操作を検出したらページのリロードを行うのですが、リロードが完了したタイミングでendRefreshing()を叩くには、WKNavigationDelegateのロード完了または失敗をハンドリングできる関数の利用が必須です。 しかし、WebUIの利用者も独自のWKNavigationDelegateWKWebViewに設定したい場合も考慮すると、Pull to Refreshを実現するためのWKNavigationDelegateとWebUI利用者独自のWKNavigationDelegateが共存できる仕組みを用意する必要があります。

Pull to Refreshのフロー図
Pull to Refreshのフロー図

ここで、WKNavigationDelegteに準拠するということはNSObjectProtocolに準拠するという点を活用して(つまりObjective-Cの技術を借りて)、responds(to:)forwardingTarget(for:)overrideすることで2つのWKNavigationDelegateを橋渡ししながら使う仕組みを実装しています。

// ライブラリを使う側(外部)のデリゲート
class ExternalNavigationDelegate: NSObject, WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {}
}

// ライブラリ側のデリゲート
class InternalNavigationDelegate: NSObject, WKNavigationDelegate {
    weak var delegate: (any WKNavigationDelegate)?

    // 流れてきたSelectorを処理可能かどうかを返す
    override func responds(to aSelector: Selector!) -> Bool {
        super.responds(to: aSelector) || delegate?.responds(to: aSelector) == true
    }

    // 流れてきたSelectorが外部のデリゲートで処理可能なときは外部のデリゲートを返す
    override func forwardingTarget(for aSelector: Selector!) -> Any? {
        guard let delegate, delegate.responds(to: aSelector) else {
            return super.forwardingTarget(for: aSelector)
        }
        return delegate
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        // 中略
        // 外部に処理を渡す
        delegate?.webView?(webView, didFinish: navigation)
    }
}

class _WKWebView: WKWebView {
    // 外部のデリゲートとライブラリ側のデリゲートを繋ぐ
    override var navigationDelegate: (any WKNavigationDelegate)? {
        get {
            internalNavigationDelegate
        }
        set {
            internalNavigationDelegate.delegate = newValue
            super.navigationDelegate = internalNavigationDelegate
        }
    }

    private let internalNavigationDelegate = InternalNavigationDelegate()

    override init(frame: CGRect, configuration: WKWebViewConfiguration) {
        super.init(frame: frame, configuration: configuration)
        super.navigationDelegate = internalNavigationDelegate
    }
}

こちらも具体的には以下のソースコードを参照してください。

WKWebViewをSwift Concurrencyのコンテキストで使用する

WebKitのAPIにはasyncawaitがついたものが存在しているため、一見Swift Concurrencyにちゃんと対応しているように思えるのですが、これらのasyncメソッドはObjective-Cのコードから自動生成されているため、細かいところで想定外の挙動を見せます。

例えばevaluateJavaScript()です。 evaluateJavaScript()には処理の結果をcompletionHandlerクロージャーで得られるインターフェースとawaitで待って得られるインターフェースが存在しています。

open func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, (any Error)?) -> Void)? = nil)

open func evaluateJavaScript(_ javaScriptString: String) async throws -> Any

前者のクロージャーパターンの場合はjavaScriptStringの中でconsole.log();window.alert();のような値を返さない処理があった際に正常に処理が実行されるのですが、後者のConcurrencyパターンの場合は「Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value」でクラッシュします。 そのため、クロージャーパターンのメソッドをSwift Concurrencyのコンテキストで使うようにしました。 withCheckedThrowingContinuation(_:)を用いたよくある変換パターンで実装しました。

func evaluateJavaScript(_ javaScriptString: String) async throws -> Any? {
    guard let webView else { return nil }
    return try await withCheckedThrowingContinuation { continuation in
        webView.evaluateJavaScript(javaScriptString) { result, error in
            if let error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume(returning: result)
            }
        }
    }
}


また、WKWebViewにはisLoadingcanGoBackをはじめとした状態を取得することが可能なプロパティがあり、これらはKVO(Key Value Observing)に対応しています。 そして、WKWebViewpublisher(for:)メソッドを経由してAsyncSequenseに準拠したAsyncPublisherであるvaluesを取得できます。 これによりSwift Concurrencyのコンテキストで値変更イベントが流れてくるのを購読できます。

let webview = WKWebView()
for await value in webview.publisher(for: \.isLoading).values {
    // isLoadingの値に変更があるたびにvalueが受け取れる
}

しかし、このままの実装では購読し始めるより前の値の変更を取りこぼすことがあります。
(参考:swift - Collecting Publisher values with async - Stack Overflow
その対応として、バッファを用いて値を取りこぼさないようにするワークアラウンドを用いています。 (本件についてはフィードバックアシスタントで報告済みです📝)

import Combine

extension Publisher where Self.Failure == Never {
    /// This is a workaround for the AsyncPublisher issue of WKWebView properties.
    func bufferedValues() -> AsyncPublisher<Publishers.Buffer<Self>> {
        self.buffer(size: 3, prefetch: .keepFull, whenFull: .dropOldest)
            .values
    }
}

let webview = WKWebView()
for await value in webview.publisher(for: \.isLoading).bufferedValues() {
    // isLoadingの値に変更があるたびにvalueが受け取れる!取りこぼしなし!
}

最後に

この記事ではWKWebViewをSwiftUIに適したインターフェースで扱えるラッパーライブラリであるWebUIについて紹介しました。 SwiftUIベースのアプリ開発をしていてWebViewの実装が必要な場合はぜひ手段の一つとして検討していただけると嬉しいです。

また、サイボウズのiOSアプリ開発コミュニティでは、社内ライブラリやOSSライブラリを開発して複数プロダクトを横断した共通化を進め、生産性の高い開発体制の整備に挑戦しています。 この取り組みにより、開発速度の向上、機能責務の分離によるメンテナンス性の向上、テストにかかるコストの削減などを目指しています。


  1. 一般的なWebページを表示するViewという意図の場合はWebView、WebUIのViewという意図の場合はWebViewと表記します。