UIViewRepresentableを使ってSwiftUIでちょっとリッチなWebviewを表示してみる

## はじめに
iOS14時点でのSwiftUIでは現在、WKWebViewに相当するViewのサポートを行っておりません。
そのため、UIKitのWKWebViewをSwiftUI用にラップして使用することが必要となります。
今回は基本的な機能(ツールバー、プログレスバー)を搭載したWebViewを表示させることをゴールとし、その中で出てくる`UIViewRepresentable`の使い方についてまとめていきたいと思います。

※ 本記事では汎用性を考え、iOS13まででサポートされている技術を使用してWebViewの表示を行います。

## UIViewRepresentableについて
UIKitのViewをSwiftUIで使用するには`UIViewRepresentable`を使用する必要があります。
`UIViewRepresentable`とはSwiftUIにてUIKitのViewを使用するためのラッパーです。
`UIViewRepresentable`のプロトコルで定義されている各関数について説明します。

```swift
func makeUIView(context: Self.Context) -> Self.UIViewType
```
実装必須。
表示するViewのインスタンスを生成します。
SwiftUIにて使用したUIKitのViewを戻り値として返却します。

```swift
func updateUIView(Self.UIViewType, context: Self.Context)
```
実装必須。
アプリの状態が更新される場合に呼ばれます。
Viewの更新がある場合は、本関数の中に記述します。

```swift
static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)
```
指定したUIKitのViewが削除される際に呼び出されます。
必要に応じて登録した通知の削除など、クリーンアップ処理を本関数内に記述します。

```swift
func makeCoordinator() -> Self.Coordinator
```
View側から通知すべきイベントがある場合に実装します。
`Coordinator`を定義することで、Delegateのようなユーザの操作によるイベントハンドリングを行うことができるようになります。

## WKWebViewを使ってWebViewを作る
では本題のWKWebViewの表示に入っていきます。

### WKWebViewを表示する
まずは単純にWebViewをSwiftUIで表示する方法です。
`UIViewType`は`associatedtype`になりますので、ここをラップしたいUIKitのViewの型に変更します。
今回はWKWebViewにします。

```swift:WebView.swift
struct WebView: UIViewRepresentable {
    /// 表示するView
    private let webView = WKWebView()
    /// 表示するURL
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        // 戻り値をWKWebViewとし、返却する
        webView.load(URLRequest(url: url))
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) { }
}
```

### ツールバーを作成する
次に`戻る` `進む` `リロード`の3つの要素を持ったツールバーを作成していきます。

#### ボタンタップ時のアクションの制御
まずはツールバーを作成し、各ボタンを配置します。
次に`Property Wrappers`を使用して、ツールバーにて各ボタンがタップされた際にWebView側で検知できるようにします。
この状態でツールバーのボタンをタップすると、アプリの状態を更新され、`UIViewRepresentable`の`updateUIView(_ uiView:, context:)`が発火します。
上記から、WebView側では`updateUIView(_ uiView:, context:)`の中にボタンに応じたアクションを記述します。

```swift:WebView.swift
struct WebView: UIViewRepresentable {
    // 省略...

    /// WebViewのアクション
    enum Action {
        case none
        case goBack
        case goForward
        case reload
    }

    /// アクション
    @Binding var action: Action

    func updateUIView(_ uiView: WKWebView, context: Context) {
        /// バインドしている値が更新されるたびに呼ばれる
        /// actionが更新されたら、更新された値に応じて処理を行う
        switch action {
        case .goBack:
            uiView.goBack()
        case .goForward:
            uiView.goForward()
        case .reload:
            uiView.reload()
        case .none:
            break
        }
        action = .none
    }
}
```

```swift:WebToolBarView.swift
struct WebToolBarView: View {
    /// アクション
    @Binding var action: WebView.Action

    var body: some View {
        VStack() {
            HStack() {
                // タップしたボタンに応じてアクションを更新
                Button("Back") { action = .goBack }
                Button("Forward") { action = .goForward }
                Button("Reload") { action = .reload }
            }
        }
    }
}
```

```swift:RichWebView.swift
struct RichWebView: View {
    /// URL
    let url: URL
    /// アクション
    @State private var action: WebView.Action = .none

    var body: some View {
        VStack() {
            WebView(url: url,
                    action: $action)
            WebToolBarView(action: $action)
        }
    }
}
```

※ `@State`や`@Binding`などの`Property Wrappers`については、本題から逸れるためここでは解説しません。
詳しく解説されている記事が多く存在しますので、必要に応じて別途ご参照下さい。
[State and Data Flow | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/state-and-data-flow)

#### ボタンの非活性化
これで、WebViewでボタンタップ時に処理をさせることが可能になりました。
しかし、どんな状態でもボタンがタップできてしまいますので、前後のページに移動できない場合は各ボタンを非活性化させるようにします。

**Coordinatorを定義する**
前後のページへ移動できるかどうかをWebViewのページ読み込みが完了したタイミングで判断するようにします。
そのためには`WKNavigationDelegate`を実装する必要がありますが、直接WebViewに実装することはできません。
そこで`UIViewRepresentable`では、`Coordinator`という**UIKitのViewから受け取った変更をSwiftUIに伝えるためのカスタムインスタンス**を作成する必要があります。
`WKNavigationDelegate`は`Coordinator`に実装し、それを通してSwiftUI側のイベントハンドリングを行います。

```swift:WebView.swift
struct WebView: UIViewRepresentable {
    // 省略... 

    /// 戻れるか
    @Binding var canGoBack: Bool
    /// 進めるか
    @Binding var canGoForward: Bool

    func makeCoordinator() -> WebView.Coordinator {
        return Coordinator(parent: self)
    }
}

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView

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

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.canGoBack = webView.canGoBack
            parent.canGoForward = webView.canGoForward
        }
    }
}
```

あとは`RichWebView`を介して`WebToolBarView`にて各値を受け取り、各ボタンの非活性、活性の処理を記述すれば完了です。

```swift:WebToolBarView.swift
    Button("Back") {　action = .goBack　}.disabled(!canGoBack)
    Button("Forward") { action = .goForward }.disabled(!canGoForward)
```

### プログレスバーを作成する
最後にプログレスバーを作成しましょう。

#### KVOで値を取ってくる
プログレスバーを作成するために`WKWebView`の`estimatedProgress`と`isLoading`を取得する必要があります。
今回はそれぞれKVOを使用して取ってきます。
Viewからの変更通知を受け取ることになりますので、先ほども使用した`Coordinator`を使用します。

````swift:WebView.swift
struct WebView: UIViewRepresentable {
    // 省略... 

     /// 読み込みの進捗状況
    @Binding var estimatedProgress: Double
    /// ローディング中かどうか
    @Binding var isLoading: Bool

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

extension WebView {
    final class Coordinator: NSObject, WKNavigationDelegate {
        /// 親View
        let parent: WebView
        /// NSKeyValueObservations
        var observations: [NSKeyValueObservation] = []

        init(parent: WebView) {
            self.parent = parent
            // 通知を登録する
            let progressObservation = parent.webView.observe(\.estimatedProgress, options: .new, changeHandler: { _, value in
                parent.estimatedProgress = value.newValue ?? 0
            })
            let isLoadingObservation = parent.webView.observe(\.isLoading, options: .new, changeHandler: { _, value in
                parent.isLoading = value.newValue ?? false
            })
            observations = [
                progressObservation,
                isLoadingObservation
            ]
        }
        // 省略...
    }
}
```

#### プログレスバーを作成する
これでプログレスバーに必要なパーツは揃いました。
あとは`ProgressBarView.swift`を作成し、`RichWebView`を介して値を受け取って表示すれば完了です。

```swift:ProgressBarView.swift
struct ProgressBarView: View {
    /// 読み込みの進捗状況
    var estimatedProgress: Double

    var body: some View {
        VStack {
            GeometryReader { geometry in
                Rectangle()
                    .foregroundColor(Color.gray)
                    .opacity(0.3)
                    .frame(width: geometry.size.width)
                Rectangle()
                    .foregroundColor(Color.blue)
                    .frame(width: geometry.size.width * CGFloat(estimatedProgress))
            }
        }.frame(height: 3.0)
    }
}
````
```swift:RichWebView.swift
struct RichWebView: View {
    // 省略...

    /// 読み込みの進捗状況
    @State private var estimatedProgress: Double = 0.0
    /// ローディング中かどうか
    @State private var isLoading: Bool = false

    var body: some View {
        VStack() {
            if isLoading {
                ProgressBarView(estimatedProgress: estimatedProgress)
            }
            // 省略...
        }
    }
}
```
これで一通りの機能を実装することができました。
エラーハンドリングなど別途考慮する点はありますが、一旦ざっくりとした機能を持ったWebViewを作成することができたのではないでしょうか。

## おわりに
今回、リッチなWebViewの表示を目指して実装を行いましたが、SwiftUIでWebViewを実装するためには`UIViewRepresentable`の基本的な機能を使用して作成する必要があるため、勉強にちょうど良いと思います。
興味ある方はぜひやってみてください。
ソースに関しては下記のgithubにまとめておきますので興味がある方がいらっしゃいましたら見ていただけると幸いです。
(github側はナビバーのタイトル表示や、レイアウトのための制約などここでは省いたコードがいくつか入っています。)
[RichWebViewSample](https://github.com/k-awoki/RichWebViewSample)

また、今回の実装について改善案やご意見等ありましたらコメントいただけますと幸いです。
どうぞよろしくお願いいたします。

## 参考文献
- [UIViewRepresentable | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/uiviewrepresentable)
- [Interfacing with UIKit — SwiftUI Tutorials | Apple Developer Documentation](https://developer.apple.com/tutorials/swiftui/interfacing-with-uikit)