2
3

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 1 year has passed since last update.

「The Ultimate Guide to WKWebView」をSwiftUIで実装する #05 - Controlling which sites can be visited -

Last updated at Posted at 2022-02-11

「The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、
5つ目になります。

この回から、WKNavigationDelegateプロトコルを使っていきます。
なので、今まで実装していなかった、makeCoordinatorメソッドを使う必要があります。

あまり詳しくは説明しませんが、
UIKitで実装したViewからSwiftUIのViewにイベントを伝えたい時に使います。

目次

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

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

環境

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

実現したいこと

今回やることは
WebView内にある特定のリンクの押下を検知して、アラートを表示させることです。

それ以外の場合は、普通に押下したリンク先のページが開くようにしています。

これができたもの。

app.gif

実現方法

まずWebViewです。

今回は、画面のフッターに戻るボタンと先に進むボタンを追加してみたこと、
あと前述したように、WKNavigationDelegateに準拠したメソッドを使用しているので、
コードが長めです。

特定のリンクの検知を行なっているのは、CoordinatorクラスにあるwebView(_:decidePolicyFor:decisionHandler:)メソッドです。

httpまたはhttpsがスキームになっているリンクがきた場合は、普通に開きますが、
sample-appがスキームになっているリンクがきた場合、かつホストがalertの場合は、アラート画面を表示するためのフラグをtrueにします。

WebView.swift
struct WebView: UIViewRepresentable {
    let url: String
    @ObservedObject var viewModel: WebViewModel

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        // これ大事。委譲先を設定しないと、webView(_:decidePolicyFor:decisionHandler:)メソッドが実行されない
        uiView.navigationDelegate = context.coordinator

        if viewModel.needsGoBack {
            uiView.goBack()
        }
        if viewModel.needsGoForward {
            uiView.goForward()
        }

        guard let url = URL(string: url) else {
            return
        }
        let request = URLRequest(url: url)
        uiView.load(request)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(self, viewModel: viewModel)
    }
}

extension WebView {
    class Coordinator: NSObject, WKNavigationDelegate {
        private let parent: WebView
        private let viewModel: WebViewModel

        init(_ parent: WebView, viewModel: WebViewModel) {
            self.parent = parent
            self.viewModel = viewModel
        }

        // decisionHandlerを実行しないケースがあると、クラッシュするので注意
        func webView(
            _ webView: WKWebView,
            decidePolicyFor navigationAction: WKNavigationAction,
            decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
        ) {
            guard let scheme = navigationAction.request.url?.scheme else {
                decisionHandler(.cancel)
                return
            }

            switch scheme {
            case "http", "https":
                // httpまたはhttpsの場合は、表示
                decisionHandler(.allow)
            case "sample-app":
                guard let host = navigationAction.request.url?.host else {
                    decisionHandler(.cancel)
                    return
                }
                // sample-app://alert の場合は、アラートを表示
                if host == "alert" {
                    viewModel.isShownAlert = true
                    decisionHandler(.cancel)
                }
            default:
                decisionHandler(.cancel)
            }
        }
    }
}

WKWebView初心者あるあるの過ちみたいですが
webView(_:decidePolicyFor:decisionHandler:)メソッドは、decisionHandlerを実行しないケースがあるとアプリがクラッシュしてしまいます。

つまり例えばこのコードで、decisionHandler(.cancel)を書き忘れると、
もしschemeがnilになるようなリンクがやってきた場合に、クラッシュしてしまいます。

私は以前これで詰まった・・・

guard let scheme = navigationAction.request.url?.scheme else {
  decisionHandler(.cancel)
  return
}

ちなみに表示しているHTMLはこんな感じ。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>HTMLを表示するテスト</h1>
    <a href="sample-app://alert">ネイティブアプリでアラートを表示</a> <br />
    <a href="https://www.apple.com/">Appleのホームページを表示</a>
  </body>
</html>

Webアプリとの行き来が頻繁に発生するようなアプリでは、ネイティブアプリ側にイベントやデータを渡すことはよくあるようです。
(あるようです。というのは、私の少ない経験上ではまだ1回しかないから...)

ちょっと話はそれますが、iOSアプリではディープリンクの方法は代表的な方法として、以下2つがあります。

  1. カスタムURLスキーム
  2. ユニバーサルリンク

今回の実装は、HTMLにあった特定のリンクの文字列をフックしただけで、カスタムURLスキームの実装とは言えない気がします。(でも一般的なディープリンクの定義には当てはまるのかな?)

ですが結構近いことはしていると思うので、
この記事とは別で、カスタムURLスキームやユニバーサルリンクについても調べてみたいと思っています。


次にWebViewを使っているViewです。

このViewに、フッターのボタンが定義されています。

WebBaseView.swift
import SwiftUI

struct WebBaseView: View {
    let url: String
    @ObservedObject var viewModel: WebViewModel

    var body: some View {
        VStack(spacing: 0) {
            WebView(url: url, viewModel: viewModel)
            footer
        }
    }
}

private extension WebBaseView {
    var footer: some View {
        HStack(alignment: .center) {
            goBackButton
            goForwardButton
            Spacer()
        }
        .padding(.horizontal, 16)
        .frame(maxWidth: .infinity, minHeight: 50)
        .background(.yellow)
    }

    var goBackButton: some View {
        Button(action: {
            viewModel.goBack()
        }) {
            Image(systemName: "chevron.backward")
        }
        .frame(width: 30, height: 30)
    }

    var goForwardButton: some View {
        Button(action: {
            viewModel.goForward()
        }) {
            Image(systemName: "chevron.forward")
        }
        .frame(width: 30, height: 30)
    }
}

そして最初のWebViewを見て気づいた方も多いと思いますが、今回はViewModelクラスを作っています。
needsGoBackneedsGoForwardがtrueになると、WebView.swift側でそれを検知して
前のページに戻る、先に進む、の処理が実行されます。

本題ではないので、最後の方にこのコードを持ってきてしまいましたが、
実装するときはもっと早い段階で作ることになるクラスだと思います。

WebViewModel.swift
import Foundation

class WebViewModel: ObservableObject {
    @Published var needsGoBack = false
    @Published var needsGoForward = false
    @Published var isShownAlert = false

    func goBack() {
        needsGoBack = true
    }

    func goForward() {
        needsGoForward = true
    }
}

では最後に、WebBaseViewを使うViewになります。

ここに、アラート画面の実装があります。

ContentView.swift
import SwiftUI

struct ContentView: View {
    private let url = "http://localhost:3000"
    @ObservedObject private var viewModel = WebViewModel()

    var body: some View {
        WebBaseView(url: url, viewModel: viewModel)
            .alert("URLスキームをフックした", isPresented: $viewModel.isShownAlert) {
                Button(action: {}) {
                    Text("OK")
                }
            }
    }
}

以上になります。

コード全体はこちらに上がっています。

今回元ネタはHacking with Swiftの「The Ultimate Guide to WKWebView」なのですが、
載っていたコードが個人的にあまり現実味がなかったので、もうちょっと実務で使われてそうな例に変更しました。

あと元の記事ではif letでOptional型のアンラップをしていたのですが、
実際書いてみたら案の定ネスト地獄になったので、guard letを使うようにしています。

その結果、原型留めないコードになりましたが、まあいいでしょう。 :sweat:

誰かの役に立ったら嬉しいです。

参考

2
3
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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?