「The Ultimate Guide to WKWebView」をSwiftUIで実装する #07 - Monitoring page loads -

[「The Ultimate Guide to WKWebView」](https://www.hackingwithswift.com/articles/112/the-ultimate-guide-to-wkwebvie)をSwiftUIで実装してみるの、
７つ目になります。

１５まであるので、大体半分まできました！ 🙌

今回はKVOだとか、SwiftUIのProgressViewだとか、WKWebViewのメソッドだとか
使ったことがないものが多くて自分には重めだった・・・

## 目次

シリーズ化していこうと思うので、全体の目次を置いておきます。
リンクが貼られていないタイトルは、記事作成中または未作成のものになります。

|#|タイトル|
|------|-----------------|
|01|[Making a web view fill the screen](https://qiita.com/kamimi01/items/b4d6cbafa587831c1be0) <br> （WebViewを画面に表示する）|
|02|[Loading remote content](https://qiita.com/kamimi01/items/c889979964820d5f9fa2) <br> （リモートのコンテンツを読み込む）|
|03|[Loading local content](https://qiita.com/kamimi01/items/9cefe1e73ab5b88051c6) <br> （ローカルのコンテンツを読み込む）|
|04|[Loading HTML fragments](https://qiita.com/kamimi01/items/2f267b328f3f6158bc26) <br> （HTMLフラグメントの読み込み）|
|05|[Controlling which sites can be visited](https://qiita.com/kamimi01/items/7c01aed497108da35b5a) <br> （訪問可能なサイトの制御）|
|06|[Opening a link in the external browser](https://qiita.com/kamimi01/items/716dccb6f109ee50c900) <br> （外部ブラウザでリンクを開く）|
|07|[Monitoring page loads](https://qiita.com/kamimi01/items/33446e032822518cb7f7) <br> （ページの読み込みを監視する）|
|08|[Reading a web page’s title as it changes](https://qiita.com/kamimi01/items/7e0153ee68474a155b9a) <br> （Webページのタイトルの変化を読み取る）|
|09|[Reading pages the user has visited](https://qiita.com/kamimi01/items/f5f6c35a6a68726fa485) <br> （ユーザーが閲覧したページを読み取る）|
|10|[Injecting JavaScript into a page](https://qiita.com/kamimi01/items/6076081dc54e2b3d0abf) <br> （JavaScriptをページに注入する）|
|11|[Reading and deleting cookies](https://qiita.com/kamimi01/items/90139fe692ec988d28bb) <br> （cookieの読み取りと削除）|
|12|[Providing a custom user agent](https://qiita.com/kamimi01/items/5884330e315338df8ec0) <br> （カスタムUser Agentを提供する）|
|13|[Showing custom UI ](https://qiita.com/kamimi01/items/3315e4df0813eda75eeb)<br> （カスタムUIを表示する）|
|14|Snapshot part of the page <br> （ページの一部のスナップショットを撮る）|
|15|Detecting data <br> （データの探索）|

## 環境

【Xcode】13.1
【Swift】5.5
【iOS】15.0
【macOS】Big Sur バージョン 11.4

## 実現したいこと

今回やることは
ページの読み込み進捗率を取得して、ナビゲーションバーの下に表示すること
です。

読み込みが完了すると、進捗率のバー（以降プログレスバー）は非表示になります。

それがメインなのですが、とても参考にさせてもらったWebViewの実装が
とても勉強になったので、それを真似て実装した結果、
戻る、進む、リフレッシュ、ついでに閉じるボタンもつけました。

がっつり普通にWebViewの実装ですね。笑

![app.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/401966/77c40348-9b43-388b-b42f-2c47bec9e794.gif)


## 実現方法

今回は本題以外の実装をしたこともあり、コードを全部載せると長いので
一部省略して書きます。

実際に動くコード全体を見たい場合は、一番最後にGithubのリンクを置いていますので、
そちらをご参考ください。

ではまずWebViewです。

```swift:WebView.swift
struct WebView: UIViewRepresentable {
    private let webView = WKWebView()

    let url: URL
    @Binding var loadingProgress: Double
    @Binding var isLoading: Bool
    @Binding var title: String

    func makeUIView(context: Context) -> WKWebView {
        webView.navigationDelegate = context.coordinator
        let request = URLRequest(url: url)
        webView.load(request)
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // 省略
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }

    static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
        coordinator.observations.removeAll()
    }
}

extension WebView {
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebView
        var observations: [NSKeyValueObservation] = []

        init(_ parent: WebView) {
            self.parent = parent

            let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in
                parent.loadingProgress = value.newValue ?? 0
            })
            let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in
                parent.isLoading = value.newValue ?? false
            })
            observations = [
                progressObservation,
                isLoadingObservation
            ]
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            // 省略
        }
    }
}
```

では詳細を見ていきましょう。

今回は、ページロードの監視つまり、読み込んでいるページがどのくらいまで読み込まれたのかを
監視してViewに描画します。

そのために監視したいのは、以下2つの値です。

- `estimatedProgress`
- `isLoading`

`estimatedProgress`は、公式ドキュメントによると
「現在のナビゲーションがどのくらい読み込まれたかの推測値」

> An estimate of what fraction of the current navigation has been loaded.

これがプログレスバーを実装するために最も重要な値になります。

`isLoading`は、
「Viewが現在コンテンツを読み込んでいるかどうかの真偽値」

> A Boolean value that indicates whether the view is currently loading content.

これは、プログレスバーの表示非表示を切り替えるために使います。

WebViewの実装サンプルや、普段使っているアプリをじっくり見てみても、
コンテンツの読み込みが完了したら、プログレスバーを非表示にしていることがほとんどのようですので、
今回この値を使って切り替えします。

次に、これらの値を監視する方法です。
**KVO（Key Value Observing）**という値監視の方法を使用して実装します。

KVOについては、書き始めるとそれだけで１つの記事にできちゃうくらいなので、
ここでは説明省きます。

代わりに、KVOについて調べたスクラップを貼っておきます。
（ご参考になれば。KVOに触れたのは今回が初めてだったので、気になることあればコメントください！）

https://zenn.dev/kamimi01/scraps/2fd3e95c35b817

ちなみにKVOの書き方は、Swift4以降で新しくなっています。
Hacking with Swiftの方は、古い書き方なので、今回は新しい書き方で実装しています。

本題に戻ります。

つまりはKVOを使用して、前述の値を監視し、
`@Binding`プロパティに代入して、Viewに逐次反映します。

ちなみにKVOの話にはなりますが、
`options`の値を指定することで、更新後の値だけではなく、更新前の値も取得することができるそうです。
今回は最新の値だけが欲しいので、`.new`を指定しています。

それによって、例のプログレスバーが出来上がります。

次に今回初めて使ったメソッドの１つとして
`dismantleUIView()`メソッドがあります。

これは、`UIViewRepresentable`プロトコルに準拠したメソッドで、
「除去されるのを見越して、UIKitのViewやcoordinatorをクリーンアップする」
ようです。
今まで使ってこなくても済んでいたのは、ドキュメントにも記載あるようにデフォルト実装がされているためですね。

> Cleans up the presented UIKit view (and coordinator) in anticipation of their removal.
Required. Default implementation provided.

とこれだけではよくわからなかったのですが、
Discussionを見ると、こうあります。

「このメソッドは、カスタムビューに関連する追加のクリーンアップ作業を実行するために使用します。たとえば、オブザーバーを削除したり、SwiftUI インターフェイスの他の部分を更新するためにこのメソッドを使うことができます。」（DeepLより）

> Use this method to perform additional clean-up work related to your custom view. For example, you might use this method to remove observers or update other parts of your SwiftUI interface.

なるほど・・・？
今回はobservationsから全てのオブザーバーを除去しています。

とここまで書いておいてなんですが、
この処理は本当に必要なのでしょうか・・・正直なところわかりませんでした・・・（この実装なくても動く。。）

ちなみに参考にした記事の、`dismantleUIView()`の実装はこうなっていました。

```swift
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
  // WKWebView削除時に呼ばれます
  // インスタンスが削除されるタイミングで通知を無効化、削除しておきます
  coordinator.observations.forEach({ $0.invalidate() })
  coordinator.observations.removeAll()
}
```

ただ、`invalidate()`はマルチスレッドで呼ばれると例外を引き起こし、
クラッシュの原因になるというのを
「[NSKeyValueObservation.invalidate()は極力避ける](https://qiita.com/SCENEE/items/4cd79cf650dd6c44c2f5)」の記事で拝見しました。

また`invalaidate()`のドキュメントコメントに
「`NSKeyValueObservation`がdeinitされる時に自動的に呼ばれる」
と記載があったのでそれを信じて、`invalidate()`メソッドは呼ばず、とりあえず配列から`removeAll()`する実装だけ残しました。（それも残す意味あったかなあ。。）

> invalidate() will be called automatically when an NSKeyValueObservation is deinited

逆に`invalidate()`メソッドがないとクラッシュするという記事もあったのですが、
今のところは特にそういった問題は起きてはないです。

どういう場合に問題が起きるのか、解明できたらまた記事更新します。。。！

---

では次に、WebViewを表示するViewです。

以下三部構成になっています。

- プログレスバー　**→今回のメイン**
- Webページを表示するView
- 戻る、進む、リロード、閉じるの４つのボタンがあるView

上の３つを、`VStack()`で重ねているサンプルも多かったのですが、
そうすると、プログレスバーが非表示になった時に、非表示になった分だけWebページの表示位置が若干上に上がるという挙動をします。

それは少し違和感があったので、
今回は`ZStack()`を使って、Webページ部分のViewの上に重ねる形でプログレスバーを配置しました。

これが正解なのかはわかりませんが、少なくとも普段よく使うアプリの挙動を見ていて
プログレスバーが非表示になった時、上に上がるような挙動はしていなかったので
何かしら位置がずれない工夫はしていると思います。

```swift:WebBaseView.swift
import SwiftUI

struct WebBaseView: View {
    let url: URL
    @State private var loadingProgress: Double = 0.0
    @State private var isLoading = false
    @State private var action: WebView.Action = .none
    @State private var canGoBack = false
    @State private var canGoForward = false
    @State private var title = ""
    @Binding var isShownWebView: Bool

    var body: some View {
        NavigationView {
            ZStack(alignment: .top) {
                VStack(spacing: 0) {
                    WebView(
                        url: url,
                        loadingProgress: $loadingProgress,
                        isLoading: $isLoading,
                        action: $action,
                        canGoBack: $canGoBack,
                        canGoforward: $canGoForward,
                        title: $title
                    )
                    WebToolBarView(
                        action: $action,
                        canGoBack: $canGoBack,
                        canGoForward: $canGoForward,
                        isShownWebView: $isShownWebView
                    )
                }
                if isLoading {
                    ProgressView(value: loadingProgress, total: 1.0)
                        .progressViewStyle(LinearProgressViewStyle())
                        .accentColor(.green)
                }
            }
            .navigationBarTitle(title, displayMode: .inline)
        }
    }
}
```

最後にこのWebBaseViewを使用する、初期表示画面のViewです。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    private let url = "https://www.apple.com/"
    @State private var isShownWebView = false

    var body: some View {
        Button(action: {
            if URL(string: url) != nil {
                isShownWebView.toggle()
            }
        }) {
            Text("WebView開く")
        }
        .fullScreenCover(isPresented: $isShownWebView) {
            WebBaseView(url: URL(string: url)!, isShownWebView: $isShownWebView)
        }
    }
}
```

以上です!

コード全体はこちらにあげています。

https://github.com/kamimi01/TheUltimateGuidetoWKWebViewWithSwiftUI/tree/main/MonitoringPageLoads

今回は、[参考](#参考)の一番上に貼った記事を、真似て実装する形になりました。
私はiOS15以上を想定して実装しているので、SwiftUIとして用意されている`ProgressView`を使用していますが、
元記事の方はiOS13をサポート対象に入っているため、まだ`ProgressView`が使えなかったようです。
代わりに`Rectangle`が使用されていました。

WKWebViewというか、KVOがわからなすぎて苦戦したし、
まだ消化不良なところもあるのですが、とても勉強になりました。

何か誤ったこと、KVO周りわかるよという人がもしいれば
コメントいただけるととっても嬉しいです。:blush:

## 参考

- [UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる](https://qiita.com/k_awoki/items/448fd0bd6f51500d13b1) →めちゃくちゃ参考にしました。SwiftUIの`ProgressView`を使用している以外はほぼほぼおんなじ実装で、解説が大変わかりやすかったです。
- [`estimatedProgress`Apple公式ドキュメント](https://developer.apple.com/documentation/webkit/wkwebview/1415007-estimatedprogress)
- [`isLoading`Apple公式ドキュメント](https://developer.apple.com/documentation/webkit/wkwebview/1414964-isloading)
- [`dismantleUIView(_:coordinator:)`Apple公式ドキュメント](https://developer.apple.com/documentation/swiftui/uiviewrepresentable/dismantleuiview(_:coordinator:)-5lee7)
- [NSKeyValueObservation.invalidate()は極力避ける](https://qiita.com/SCENEE/items/4cd79cf650dd6c44c2f5)