iOS向けの最小限機能のブラウザーを開発中🛠

Webサービスを先行してリリースした後に後発でモバイル向けアプリをリリースする場合、WebViewベースのアプリ（いわゆるガワネイティブ）となることが結構あると思います。このガワネイティブの実装では、WebView上でのイベントをフックしてネイティブのUIを表示したり、ネイティブ側で特定の処理を実行したりといった要件がありますよね。

また、SwiftUIではまだWebViewが提供されていないため`WKWebView`を`UIViewRepresentable`でラップして利用する必要があります。ただ、WebViewのインタラクション（検索・戻る・進む・リロードなど）までラップしているサンプルはGitHubなどを調査してもあまり見当たりません（あったとしても多機能すぎたり実装がよくない）でした。

このような事情から、WebViewの仕様を学び、扱う練習を目的としてブラウザを自作してみることにしました。

## 要件

- SwiftUIベースのアプリにする
- [デフォルトブラウザの要件](https://developer.apple.com/documentation/xcode/preparing-your-app-to-be-the-default-browser-or-email-client)を満たす
- 別のアプリからの共有でリンクを開ける

# MinBrowser

MIT Licenseのオープンソースプロジェクトとして開発しています。
https://github.com/Kyome22/MinBrowser

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/8b441656-b6c0-e331-78e2-0629fedecf47.png" alt="1-top" width="150px" />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/705ac68f-932b-e20a-b47d-e8f2269daa70.png" alt="2-open-link" width="150px" />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/eec06315-bb37-f6f3-6213-3017b88a5ea9.png" alt="4-bookmark" width="150px" />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/48e6a528-caf7-92b1-1727-6b57751f8e2f.png" alt="6-share-view" width="150px" />

## 環境

- 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`も同様。
- 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から本体アプリを起動したい場合は黒魔術が必要です。<br>[Share Extensionから本体アプリを開く](https://zenn.dev/kyome/articles/88876501b05f13)
- SwiftUIの場合は、本体アプリ側で`onOpenURL()`を使ってカスタムスキームURLを受け取ることができます。
  - クエリ文字列があればそれも取得できるので、開くべきリンクも受け取れます。

# 最後に

`UIViewRepresentable`の実装において、UIKitのView側からSwiftUIのViewにイベントを伝達するのは簡単ですが、逆にSwiftUIのViewからUIKitのViewにイベントを送るのがかなり手こずりました。structであるViewのBindingプロパティが更新されると、Viewそのものが作り直されてしまうケースがあり、WebViewの状態を維持しながら表示を更新するのが難しかったです。一度は諦めたのですがちゃんとやり切れてよかったです。

ソースは公開してあるので、WkWebViewをSwiftUIでどうラップするかの参考にしてもらってもいいですし、野良のブラウザーアプリが必要なデバッグに使っていただけると嬉しいです。