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の
alert
confirm
prompt
をネイティブで表示するために、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でどうラップするかの参考にしてもらってもいいですし、野良のブラウザーアプリが必要なデバッグに使っていただけると嬉しいです。