2
2

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.

SwiftUIAdvent Calendar 2022

Day 11

「The Ultimate Guide to WKWebView」をSwiftUIで実装する #13 - Showing custom UI -

Last updated at Posted at 2022-12-11

The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、
13個目になります。12個目を投稿以降、しばらくサボっていてごめんなさい。 :bow:

今回はWebViewで新しいウィンドウを開く方法についてです。
SwiftUIだと色々ハマりまりました。正直SwiftUIが悪いのか私が悪いのか不明なところも残っています。(いつもか、、)
色々な方法を試してみたつもりではあるのですが、他にもっと良い方法があるんじゃないかとも思うので、もしあればコメントで優しく教えてくださるととてもありがたいです。 :pray:

目次

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

# タイトル
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】14.1
【Swift】5.7.1
【iOS】16.0
【macOS】Montrey バージョン 12.6

※前回(12個目の記事)から少しバージョンが上がっています。

実現したいこと

以下のボタンをそれぞれ押下したときに、ネイティブ側で各アラートを表示します。
荒いGIFですみません..

mov

実現方法

WebViewではJavaScriptで実行されるAlertやConfirm、Promptはデフォルトでは表示されませんので、その実装をしていきます。
WKUIDelegateプロトコルに準拠させてそれぞれメソッドを実装していきます。

実装が必要なメソッドは以下の3つです。

  • Alert:runJavaScriptAlertPanelWithMessage
    • message:表示されるメッセージ
    • completionHandler:アラート画面を閉じたら呼ぶ
  • Confirm:runJavaScriptConfirmPanelWithMessage
    • message:表示されるメッセージ
    • completionHandler:確認画面を閉じたら呼ぶ。OKを選択したらtrueをキャンセルを選択したらfalseを渡す
  • Prompt:runJavaScriptTextInputPanelWithPrompt
    • prompt:表示されるメッセージ
    • defaultText: テキストフィールドに初期表示されるテキスト
    • completionHandler: テキスト入力画面を閉じたら呼ぶ。OKを選択したら入力されたテキストを渡し、そうでなければnilを渡す

SwiftUIの場合ここで問題になるのが、どのように各アラート画面を表示するか、です。
こちらの元記事はUIKitで実装されているのでpresent(_:animated:completion:)を使って表示していましたが、これはUIViewControllerのインスタンスメソッドなのでそのままでは使えません。

調べたところやり方はいくつかありましたが、今回は3つ書きます。最終的に2つ目の方法を選びました。と最初は書いていたのですが多分3つ目が一番シンプルです。。

方法① 最前画面のUIViewControllerを取得して、UIKitの実装でアラートを表示する

これは書いてある通り

  1. どうにかして最前画面のUIViewControllerを取得する
  2. 各アラートのViewをUIKitで実装する
  3. present(_:animated:completion:)を使って表示する

という方法です。

前述した通りこの方法は選びませんでした。 以下の記事を参考にしたところ、以下のようなデメリットがあると分かったからです。

  • 実装が複雑になりがち:UINavigationControllerUITabBarControllerの場合も考慮して実装をしなければいけない
  • 最前面の画面クラスが死ぬ可能性がある

あと個人的にはAppleの仕様変更でViewの階層が何かしら変わったりしたらと考えるとなんか色々怖いです。そんなことはないのかな?わからない。。

とはいえ一応実装はして動きはしたのでコードを載せておきます。
ちなみにコード全体が見たい場合は、このコミットをご参考ください。

WebView.swift
// Coordinatorクラス側の実装だけ載せています
extension WebView {
    class Coordinator: NSObject, WKUIDelegate {
        var parent: WebView

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

        func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
            let alert = UIAlertController(title: "Hey, Listen!", message: message, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "OK", style: .default))

            if let controller = topMostViewController() {
                controller.present(alert, animated: true)
            }
            completionHandler()
        }

        func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
            let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
            alert.addAction(.init(title: "OK", style: .default, handler: { _ in
                completionHandler(true)
            }))
            alert.addAction(.init(title: "キャンセル", style: .cancel, handler: { _ in
                completionHandler(false)
            }))
            if let controller = topMostViewController() {
                controller.present(alert, animated: true)
            }
        }

        func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
            let alert = UIAlertController(title: nil, message: prompt, preferredStyle: .alert)
            alert.addTextField { textField in
                textField.text = defaultText
            }
            alert.addAction(.init(title: "OK", style: .default, handler: { _ in
                completionHandler(alert.textFields?.first?.text)
            }))
            alert.addAction(.init(title: "キャンセル", style: .cancel, handler: { _ in
                completionHandler(nil)
            }))
            if let controller = topMostViewController() {
                controller.present(alert, animated: true)
            }
        }

        /// アクティブな画面のViewControllerを取得する
        private func topMostViewController() -> UIViewController? {
            guard let rootController = keyWindow()?.rootViewController
            else { return nil }
            return topMostViewController(for: rootController)
        }

        /// アクティブなUIWindowを取得する
        private func keyWindow() -> UIWindow? {
            return UIApplication.shared.connectedScenes
                .filter { $0.activationState == .foregroundActive }
                .compactMap { $0 as? UIWindowScene }
                .first?.windows.filter { $0.isKeyWindow }.first
        }

        private func topMostViewController(for controller: UIViewController) -> UIViewController {
            if let presentedController = controller.presentedViewController {
                return topMostViewController(for: presentedController)
            }
            return controller
        }
    }
}

参考にしたのは、以下の記事です。 元々はアラートのViewはSwiftUIで実装して、表示を切り替えるフラグなどはBindingしようと思って実装したのですが、なぜかアラート画面が表示されませんでした。。その結果、こうしてUIKitで頑張りました。。つら。
追記:後から気づきましたが、データのBindingがうまく行っていないだけみたいなので、3番目にEnvironmentObjectを使った方法を追記しました。Bindingがうまくいかない理由が分かったらまた更新します。 :bow:

方法② アラート用に新たにWindowを追加し、SwiftUIの実装でアラートを表示する

最終的にはこちらを採用しました。と最初は書いていたのですが、3番目を採用するのがまだマシそうです。こちらはWindowを追加すると言う方法もあるんだへーくらいで。。 :pray:
③の方法を使ってもどうしても一番上に表示できないというケース、何があっても一番上に表示させたいものがあるケースにこちらを使うことになりそうです。
以下の記事をめちゃくちゃ参考にしました。

やっていることは以下の通りです。

  1. アラートを表示させるためのWindowを新たに追加する
  2. 各アラートのViewをSwiftUIで実装する
  3. EnvironmentObjectで値を受け渡してアラートを表示する

方法①の方で、実装が複雑になると言うデメリットを上げましたが、こっちの方が複雑な気もしました。。なので①を採用しなかった理由は、「最前面の画面クラスが死ぬ可能性がある」かなあと思いました。実際どのくらいの可能性で死ぬのかはわかりませんが。。

まずWindowの追加です。setupDialogWindow()でやっています。

ShowingCustomUIApp.swift
@main
struct ShowingCustomUIApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(DialogManager())
        }
    }
}

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        config.delegateClass = SceneDelegate.self
        return config
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    lazy var dialogManager = DialogManager()
    var keyWindow: UIWindow?
    var dialogWindow: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let windowScene = scene as? UIWindowScene {
            setupKeyWindow(in: windowScene)
            setupDialogWindow(in: windowScene)  // アラート用のWindowを追加
        }
    }

    private func setupKeyWindow(in scene: UIWindowScene) {
        let window = UIWindow(windowScene: scene)
        window.rootViewController = UIHostingController(rootView: ContentView().environmentObject(dialogManager))
        self.keyWindow = window
        window.makeKeyAndVisible()
    }

    private func setupDialogWindow(in scene: UIWindowScene) {
        let dialogWindow = DialogWindow(windowScene: scene)
        let dialogWindowController = UIHostingController(rootView: DialogView().environmentObject(dialogManager))
        dialogWindowController.view.backgroundColor = .clear  // クリアにしないとkeyWindowがdialogWindowにかぶさってしまう
        dialogWindow.rootViewController = dialogWindowController
        dialogWindow.isHidden = false  // makeKeyAndVisible()を呼ばない代わりに、falseにして確実に表示
        self.dialogWindow = dialogWindow
    }
}

またDialogWindow()ですが、何もしないとメインのWindowの上に被さっていてタップしたときの判定がメインのWindowではなく、DialogWindow()の方までで止まってしまうので、その判定を透過するための処理をオーバーライドしています。

実装はこちらを参考にしています。

DialogWindow.swift
class DialogWindow: UIWindow {
    // このwindowに対するタップ判定を透過させる
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        let view = super.hitTest(point, with: event)
        if view == self { return nil }
        if view == rootViewController?.view { return nil }
        return view
    }
}

あとWindow間のデータの受け渡しには、EnvironmentObjectを使用しました。今回初めて使った。。
受け渡しているデータは以下の通りです。主にダイアログを表示するかどうかのフラグと表示するテキスト、ボタン押下時のコールバックです。

DialogManager.swift
class DialogManager: ObservableObject {
    // アラートダイアログ
    @Published var showAlert = false
    @Published var alertMessage = ""
    @Published var alertCompletion: () -> Void = {}

    // 確認ダイアログ
    @Published var showConfirmAlert = false
    @Published var confirmMessage = ""
    @Published var confirmCompletion: (Bool) -> Void = { _ in }

    // プロンプト
    @Published var showPromptAlert = false
    @Published var promptDefaultText = ""
    @Published var promptMessage = ""
    @Published var promptCompletion: (String?) -> Void = { _ in}
}

この値の書き換えを行なっているのが、以下のコードになります。

WebView.swift
struct WebView: UIViewRepresentable {
    let url: URL
    private let webView = WKWebView()
    @EnvironmentObject var dialogManager: DialogManager

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

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

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

extension WebView {
    class Coordinator: NSObject, WKUIDelegate {
        var parent: WebView

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

        func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
            self.parent.dialogManager.showAlert = true
            self.parent.dialogManager.alertMessage = message
            self.parent.dialogManager.alertCompletion = completionHandler
        }

        func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
            self.parent.dialogManager.showConfirmAlert = true
            self.parent.dialogManager.confirmMessage = message
            self.parent.dialogManager.confirmCompletion = completionHandler
        }

        func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
            self.parent.dialogManager.showPromptAlert = true
            if let defaultText = defaultText {
                self.parent.dialogManager.promptDefaultText = defaultText
            }
            self.parent.dialogManager.promptMessage = prompt
            self.parent.dialogManager.promptCompletion = completionHandler
        }
    }
}

各アラートのViewの実装は以下のようになっています。Dialog用のWindowにはアラートしか表示しないのですが、alertモディファイアは何かしらのViewに対して付くものなので、今回はEmptyView()を使っています。

また押されたボタンに応じて必要なcompletionを呼び出す形となっています。呼び忘れると、アラートのボタン押下後にいくらwebview上のボタンを押してもアラートが表示されないという現象が起きました。

DialogView.swift
struct DialogView: View {
    @EnvironmentObject var dialogManager: DialogManager

    var body: some View {
        EmptyView()
            .alert("Hey, Listen!", isPresented: $dialogManager.showAlert) {
                Button("OK") {
                    dialogManager.alertCompletion()
                }
            } message: {
                Text(dialogManager.alertMessage)
            }
            .alert("Hey, Listen2!", isPresented: $dialogManager.showConfirmAlert) {
                Button("OK") {
                    dialogManager.confirmCompletion(true)
                }
                Button("キャンセル") {
                    dialogManager.confirmCompletion(false)
                }
            } message: {
                Text(dialogManager.confirmMessage)
            }
            .alert("Hey, Listen3!", isPresented: $dialogManager.showPromptAlert) {
                TextField(dialogManager.promptMessage, text: $dialogManager.promptDefaultText)
                Button("OK") {
                    dialogManager.promptDefaultText = ""
                    dialogManager.promptCompletion(dialogManager.promptDefaultText)
                }
                Button("キャンセル") {
                    dialogManager.promptDefaultText = ""
                    dialogManager.promptCompletion(nil)
                }
            } message: {
                Text(dialogManager.promptMessage)
            }
    }
}

あとついでにこの実装の懸念点なのですが、なぜかThis method should not be called on the main thread as it may lead to UI unresponsiveness.のエラーが出ます。。
理由は結局わからなかったのですが、分かったことはWebViewを表示しようとすると起きることがわかりました。。なのでこの実装でいいのかどうもしっくりこないのですが、現状うまく行っているのがこれで、以下のスレッドを確認していた限り実装に問題があるかもちょっとわからない状態です。。誰かわかればコメントください。。

以上です!

コード全体は以下に上がっています。

方法③ Windowは追加せず、SwiftUIでアラートを実装する

この記事を投稿後に追加しました。そもそも元々はこちらの方と同じ方法でやっていたのですが、@Bindingだとなぜかデータがうまく受け渡しできていないためか、アラートが表示されませんでした。なので、Bindingはやめてもっと強力なEnvironmentObjectで受け渡したらうまくいきました。

グローバルな変数を使いたくないために色々迷走してきましたが、方法①や②のように複雑になるくらいだったら、③の方が多少マシなんでしょうか・・?

②との違いは、windowを新しく作らないことです。(なんで作っていたんでしょうかって思っちゃう...①を改善しようと色々やっていたら迷走していました...)
それだけでだいぶコードはスッキリします。

②に必要なコードはありますので、ここに載せるのは省略させてください。 :pray: コード全体はこちらに置いています。


SwiftUIでカバーされてないものを使おうとするとやっぱり大変だなということを思い知らされました。。でもWebViewの実装があるアプリであれば、JSの実行がある場合もあると思いますので、やむなしですかね・・・ :sob:

もっと良い方法あるよ、ここ間違っているよなどあれば、コメントで教えていただけますと幸いです。

参考

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?