はじめに
こんにちは、iOSエンジニアのKyome(@Kyomesuke)です。
私が担当しているkintoneのモバイルアプリはWKWebView
を用いたハイブリッドアプリなのですが、UIKitからSwiftUIへのリプレイスにあたってWebViewをSwiftUIに適したインターフェースで(つまり宣言的UIで)実装したいという需要が高まっていました。
しかし現在、SwiftUIと互換性のある宣言的UIなWebViewのAPIはAppleから提供されていません。
そこで、WKWebView
をSwiftUIに適したインターフェースで扱えるようにしたラッパーライブラリを開発しました!
この記事では、開発したライブラリであるWebUIの使い方と工夫した点を紹介します。
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
にはtitle
やisLoading
、canGoBack
など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) } }
次に、WKUIDelegate
やWKNavigationDelegate
に準拠したクラスを設定するための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の使い方について詳しく参照したい場合はこちらを活用してください。
工夫した点
SwiftUIに適したインターフェース設計
こちらは今回の最大の目的であるためかなり慎重に設計しました。
ただ単純にWKWebView
をUIViewRepresentable
でラップしてSwiftUIのコンテキストで使えるようにするだけでなく、宣言的UIとして不整合なく書けるようにすること、SwiftUI既存のAPIと同様なインターフェースで自然に書けるようにすることなどを意識しました。
この設計ではScrollView
とScrollViewReader
、GeometryReader
、Text
、View.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でも同様にWebView
、WebViewReader
、WebViewProxy
を提供することにしました。
- 任意のページを表示するだけの場合、
WebView
のみを用いて実装可能にする WKWebView
固有の挙動設定(例えばisInspectable
やallowsLinkPreview
)に関しては、WebView
に直接生えたModifierを介して設定できる- Modifierを適用したViewの型は
WebView
のままである
- Modifierを適用したViewの型は
WebViewReader
のスコープでWebView
を囲うことでWebViewProxy
を介してload(_:)
やgoBack()
のようなWKWebView
の操作が行えるWebViewReader
のスコープでWebView
を囲うことでWebViewProxy
を介してisLoading
やcanGoBack
のようなWKWebView
の状態を取得できる
最終的に上記のような方針でインターフェースを提供することにしました。
SwiftUIに適したインターフェース実装
上記で最高なインターフェースの設計はできましたが、その実装にも工夫が必要でした。
UIViewRepresentable.updateUIView(_:context:)の無限ループ対策
WKWebView
をSwiftUIのViewで提供するにはUIViewRepresentable
を用いるのですが、不注意に実装していくと無限ループの不具合を容易に起こします。
WKWebView
の持つ状態(isLoading
やcanGoBack
など)を別のViewで扱えるようにObservableObject
に準拠したクラスや@Observable
マクロを適用したクラスなどに同期させることを考えます。
この時、WKWebView
の操作(load(_:)
やreload()
、goBack()
など)を行うためにUIViewRepresentable
と先述のクラスを紐づけると、その実装の仕方次第ではWKWebView
の状態更新とそれによるViewの再描画が無限に連鎖してしまいます。
より具体的には
UIViewRepresentable
を介してWKWebView
に対してロードのリクエストを送るWKWebView
のisLoading
が更新されるisLoading
の値の更新がObservableObject
に準拠したクラスに伝搬されるObservableObject
のobjectWillChange
が発火されるobjectWillChange
によりUIViewRepresentable.updateUIView(_:context:)
が発火されるWKWebView
に対してしてロードのリクエストが再び送られる←2へ戻り、以降無限ループ
のような無限ループに陥ります。
この問題を回避するためには、状態としてのWKWebView
をUIViewRepresentable
の外に逃すこと、WKWebView
の状態が更新された際にそれがUIViewRepresentable
に伝播しないようにすることの2つが肝要です。
WebUIではWKWebView
をUIViewRepresentable
の外に持ち出すための仕組みをEnvironmentValues
を用いて実現しています。
具体的には以下のソースコードを参照してください。
Pull to Refreshの実装
Pull to Refreshといえば、画面を下にスクロールして限界を超えて引っ張りコンテンツの更新を促す操作ですが、WebUIのWebView
では標準でこの機能を提供するModifierを提供しています。
ただ、この機能の実装において厄介なポイントがいくつかありました。
WKWebView
の持つUIScrollView
にUIRefreshControl
をセットすることで実装していくのですが、UIRefreshControl
は任意の更新処理が完了した際にendRefreshing()
というメソッドを叩く必要があります。
今回はPull to Refreshの操作を検出したらページのリロードを行うのですが、リロードが完了したタイミングでendRefreshing()
を叩くには、WKNavigationDelegate
のロード完了または失敗をハンドリングできる関数の利用が必須です。
しかし、WebUIの利用者も独自のWKNavigationDelegate
をWKWebView
に設定したい場合も考慮すると、Pull to Refreshを実現するためのWKNavigationDelegate
とWebUI利用者独自のWKNavigationDelegate
が共存できる仕組みを用意する必要があります。
ここで、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にはasync
やawait
がついたものが存在しているため、一見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
にはisLoading
やcanGoBack
をはじめとした状態を取得することが可能なプロパティがあり、これらはKVO(Key Value Observing)に対応しています。
そして、WKWebView
のpublisher(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ライブラリを開発して複数プロダクトを横断した共通化を進め、生産性の高い開発体制の整備に挑戦しています。 この取り組みにより、開発速度の向上、機能責務の分離によるメンテナンス性の向上、テストにかかるコストの削減などを目指しています。
-
一般的なWebページを表示するViewという意図の場合はWebView、WebUIのViewという意図の場合は
WebView
と表記します。↩