Webサービスを先行してリリースした後に後発でモバイル向けアプリをリリースする場合、WebViewベースのアプリ(いわゆるガワネイティブ)となることが結構あると思います。このガワネイティブの実装では、WebView上でのイベントをフックしてネイティブのUIを表示したり、ネイティブ側で特定の処理を実行したりといった要件がありますよね。
また、SwiftUIではまだWebViewが提供されていないためWKWebViewをUIViewRepresentableでラップして利用する必要があります。ただ、WebViewのインタラクション(検索・戻る・進む・リロードなど)までラップしているサンプルはGitHubなどを調査してもあまり見当たりません(あったとしても多機能すぎたり実装がよくない)でした。
このような事情から、WebViewの仕様を学び、扱う練習を目的としてブラウザを自作してみることにしました。
要件
- SwiftUIベースのアプリにする
- デフォルトブラウザの要件を満たす
- 別のアプリからの共有でリンクを開ける
MinBrowser
MIT Licenseのオープンソースプロジェクトとして開発しています。
https://github.com/Kyome22/MinBrowser
環境
- Swift 5+
- iOS 15.0+
- Xcode 13.4.1+
現状の機能
- HTTP/HTTPSのリンクを開く
- キーワード検索(Google/Bing/DuckDuckgo)
- フルスクリーン(検索バーとツールバーを隠す)
- Pull to Refresh(スクロールダウンするとリロードするやつ)
- ブックマーク機能
- 別アプリからリンクをMinBrowserで開く
- ライトモード/ダークモード対応
- 他言語対応(英語/日本語)
実装ポイント
WKWebViewをUIViewRepresentableでラップする
-
UIViewRepresentableの中でViewModelを@StateObjectを持つとインタラクションがバグるので、@ObservedObjectで持つようにする。 - SwiftUI側からのインタラクションを受け取るために、actionを
BindingしてupdateUIViewで処理を捌く。ただし、ここで上手に実装しないと半無限ループが起き挙動がかなり怪しいブラウザーになってしまうため注意が必要。 -
UIViewRepresentableはstructのため、WebViewのプロパティ変化を監視するならclassであるCoordinatorの中で購読するようにする。- Pull to Refreshのための
UIRefreshControlも同様。
- Pull to Refreshのための
- UserAgentでモバイルと認識させるために、
WKNavigationDelegateに準拠してWKWebpagePreferences.preferredContentModeを.mobile指定する。 - カスタムスキームやドメインでの挙動を分岐するために、
WKNavigationDelegateに準拠してdecidePolicyFor navigationActionを上手に実装する。 - JavaScriptの
alertconfirmpromptをネイティブで表示するために、WKUIDelegateに準拠してそれぞれのイベントをフックする。- ユーザーの選択や入力結果を待つため、コールバックによる非同期処理を上手に捌く。
- SwiftUIの
.alert()はiOS 16未満ではTextFieldの付きのアラート表示に対応していないので、こちらもUIAlertControllerをViewModifier経由で呼び出す実装が必要。
検索バーやツールバーの実装
SwiftUIのNavigationViewのViewModifierである.searchableや.toolbarがそのまま使えれば実装が楽だったのですが、検索バーやツールバーのレイアウトやデザインを自由に設定できない(かなり自由度が低い)ため断念して自前実装しました。NavigationViewは独特な仕様があるので意外と使い勝手が悪いです。
ブックマークの実装
本当はハーフモーダルで表示したかったのですが、SwiftUIの.sheetはまだハーフモーダルに対応していないので諦めました。
外部アプリ経由でリンクを開く実装
- Share Extensionを実装する。
- SwiftUIをなるべく使いたいなら、デフォルトで用意されている
ShareViewControllerはSLComposeServiceViewControllerを継承せずにUIViewControllerを継承して、内部でUIHostingControllerでSwiftUIのViewを呼び出す。 - Share Extensionから本体アプリを起動したい場合は黒魔術が必要です。
Share Extensionから本体アプリを開く
- SwiftUIをなるべく使いたいなら、デフォルトで用意されている
- SwiftUIの場合は、本体アプリ側で
onOpenURL()を使ってカスタムスキームURLを受け取ることができます。- クエリ文字列があればそれも取得できるので、開くべきリンクも受け取れます。
最後に
UIViewRepresentableの実装において、UIKitのView側からSwiftUIのViewにイベントを伝達するのは簡単ですが、逆にSwiftUIのViewからUIKitのViewにイベントを送るのがかなり手こずりました。structであるViewのBindingプロパティが更新されると、Viewそのものが作り直されてしまうケースがあり、WebViewの状態を維持しながら表示を更新するのが難しかったです。一度は諦めたのですがちゃんとやり切れてよかったです。
ソースは公開してあるので、WkWebViewをSwiftUIでどうラップするかの参考にしてもらってもいいですし、野良のブラウザーアプリが必要なデバッグに使っていただけると嬉しいです。