29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

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

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

UIViewRepresentableについて

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

func makeUIView(context: Self.Context) -> Self.UIViewType

実装必須。
表示するViewのインスタンスを生成します。
SwiftUIにて使用したUIKitのViewを戻り値として返却します。

func updateUIView(Self.UIViewType, context: Self.Context)

実装必須。
アプリの状態が更新される場合に呼ばれます。
Viewの更新がある場合は、本関数の中に記述します。

static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

指定したUIKitのViewが削除される際に呼び出されます。
必要に応じて登録した通知の削除など、クリーンアップ処理を本関数内に記述します。

func makeCoordinator() -> Self.Coordinator

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

WKWebViewを使ってWebViewを作る

では本題のWKWebViewの表示に入っていきます。

WKWebViewを表示する

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

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側で検知できるようにします。
この状態でツールバーのボタンをタップすると、アプリの状態を更新され、UIViewRepresentableupdateUIView(_ uiView:, context:)が発火します。
上記から、WebView側ではupdateUIView(_ uiView:, context:)の中にボタンに応じたアクションを記述します。

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
    }
}
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 }
            }
        }
    }
}
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

ボタンの非活性化

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

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

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にて各値を受け取り、各ボタンの非活性、活性の処理を記述すれば完了です。

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

プログレスバーを作成する

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

KVOで値を取ってくる

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

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)
    }
}
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

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

参考文献

29
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?