「The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、
13個目になります。12個目を投稿以降、しばらくサボっていてごめんなさい。
今回はWebViewで新しいウィンドウを開く方法についてです。
SwiftUIだと色々ハマりまりました。正直SwiftUIが悪いのか私が悪いのか不明なところも残っています。(いつもか、、)
色々な方法を試してみたつもりではあるのですが、他にもっと良い方法があるんじゃないかとも思うので、もしあればコメントで優しく教えてくださるととてもありがたいです。
目次
シリーズ化していこうと思うので、全体の目次を置いておきます。
リンクが貼られていないタイトルは、記事作成中または未作成のものになります。
# | タイトル |
---|---|
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ですみません..
実現方法
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の実装でアラートを表示する
これは書いてある通り
- どうにかして最前画面のUIViewControllerを取得する
- 各アラートのViewをUIKitで実装する
-
present(_:animated:completion:)
を使って表示する
という方法です。
前述した通りこの方法は選びませんでした。 以下の記事を参考にしたところ、以下のようなデメリットがあると分かったからです。
- 実装が複雑になりがち:
UINavigationController
やUITabBarController
の場合も考慮して実装をしなければいけない - 最前面の画面クラスが死ぬ可能性がある
あと個人的にはAppleの仕様変更でViewの階層が何かしら変わったりしたらと考えるとなんか色々怖いです。そんなことはないのかな?わからない。。
とはいえ一応実装はして動きはしたのでコードを載せておきます。
ちなみにコード全体が見たい場合は、このコミットをご参考ください。
// 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がうまくいかない理由が分かったらまた更新します。
方法② アラート用に新たにWindowを追加し、SwiftUIの実装でアラートを表示する
最終的にはこちらを採用しました。と最初は書いていたのですが、3番目を採用するのがまだマシそうです。こちらはWindowを追加すると言う方法もあるんだへーくらいで。。
③の方法を使ってもどうしても一番上に表示できないというケース、何があっても一番上に表示させたいものがあるケースにこちらを使うことになりそうです。
以下の記事をめちゃくちゃ参考にしました。
やっていることは以下の通りです。
- アラートを表示させるためのWindowを新たに追加する
- 各アラートのViewをSwiftUIで実装する
-
EnvironmentObject
で値を受け渡してアラートを表示する
方法①の方で、実装が複雑になると言うデメリットを上げましたが、こっちの方が複雑な気もしました。。なので①を採用しなかった理由は、「最前面の画面クラスが死ぬ可能性がある」かなあと思いました。実際どのくらいの可能性で死ぬのかはわかりませんが。。
まずWindowの追加です。setupDialogWindow()
でやっています。
@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()
の方までで止まってしまうので、その判定を透過するための処理をオーバーライドしています。
実装はこちらを参考にしています。
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
を使用しました。今回初めて使った。。
受け渡しているデータは以下の通りです。主にダイアログを表示するかどうかのフラグと表示するテキスト、ボタン押下時のコールバックです。
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}
}
この値の書き換えを行なっているのが、以下のコードになります。
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上のボタンを押してもアラートが表示されないという現象が起きました。
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を新しく作らないことです。(なんで作っていたんでしょうかって思っちゃう...①を改善しようと色々やっていたら迷走していました...)
それだけでだいぶコードはスッキリします。
②に必要なコードはありますので、ここに載せるのは省略させてください。 コード全体はこちらに置いています。
SwiftUIでカバーされてないものを使おうとするとやっぱり大変だなということを思い知らされました。。でもWebViewの実装があるアプリであれば、JSの実行がある場合もあると思いますので、やむなしですかね・・・
もっと良い方法あるよ、ここ間違っているよなどあれば、コメントで教えていただけますと幸いです。
参考
- Swift UIWindow 活用術 〜やめよう!最前画面の取得〜
- How to add a TextField to Alert in SwiftUI?
- How do a send back the result of an Alert confirmation from ContentView back to a Coordinator of a UIViewRepresentable?
- How to layer multiple windows in SwiftUI
- 【SwiftUI】@EnvironmentObjectの使い方を徹底解説
- WKWebViewで、JavaScriptのAlert, Confirm, Promptを表示する方法
- 【Swift】makeKeyAndVisibleってなにしてるんだっけ?
- 【SwiftUI】SceneDelegateを使う方法(2パターン)