ついにというかようやくというかUIWebView
がiOS12でdeprecatedとなりました!
それに伴い、これまでしぶとくUIWebView
を使っていたアプリもWKWebView
へと移行せざるを得なくなってきたかもしれません。
それで、UIWebView
からの移行で困るポイントの一つである「新しいウィンドウが開けない問題」をまとめ直してみました。
WKWebViewで新しいウィンドウ(タブ)を開くには
UIWebView
では、新しいウィンドウ(タブ)を開くリンクをタップすると、同じWebViewの中でそのまま遷移していました。
ところが、WKWebView
ではそういったリンクをタップしても無視される為、新しいウィンドウ(タブ)を開くリンクがタップされた時にどういった処理をするのかをアプリ側で実装する必要があります。
(ちなみに、UIWebView
だけでなくSFSafariViewController
、AndroidのWebView
も同じWebView内でそのまま遷移します)
なお、HTMLで新しいウィンドウ(タブ)を開く方法には以下の方法があります。
-
<a>
や<form>
タグなどでtarget="_blank"
を指定する - javascriptで
window.open
を実行する
対応方法
ベストな対応方法は、新しいウィンドウ(タブ)を開くようなWebの構成は、スマホのブラウザとは相性が悪いのでやめてしまうというものです。
次点として、単にアプリからWebサイトを開かせたいといった場合はWKWebView
ではなくSFSafariViewController
を使う方が良いです。
とは言っても、そうもいかないこともある1ので、その場合は実装で対応することになります。。。
対応方法1:同じWebView内で開く
最も一般的な方法はUIWebView
の挙動を踏襲して、新しい画面で開くページを元の画面と同じWebViewでそのまま開くというものです。
(QiitaでもWKWebViewでtarget="_blank"なリンクが開かない時の対処法という記事があります)
実装としては、WKUIDelegate
のwebView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:
を以下のように実装し、WKWebView
のuiDelegate
にセットします。
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView?
{
if navigationAction.targetFrame?.isMainFrame != true {
webView.load(navigationAction.request)
}
return nil
}
ただし、この方法には落とし穴が3つあります。
問題点1.1:元の画面のページを上書きして読み込んでしまう
先ほどの記事のコメント欄で指摘されている点です。
target="_blank" を強引にメインのwebViewにロードさせる場合は注意が必要です。
例えばイベントサイトのATNDで参加登録をした時、
メインフレームで参加登録リクエストが投げられ、別ウィンドウでTwitterへの投稿画面が開かれようとします。このとき、別ウィンドウで開かれようとしているTwitterへの投稿画面を、
参加登録リクエストを送信するはずだったwebViewに読み込ませてしまうと、
肝心の参加登録処理が実行されないままになってしまいます。
なお、この問題は他のWebViewの場合でも発生します。
問題点1.2:window.close()
ができない
よくあるポップアップ画面だと、元の画面に戻りやすい様に「閉じる」ボタンがついています。そして「閉じる」をタップするとjavascriptでwindow.close()
を呼び出し、新しく開いた画面を閉じる仕組みになっています。
ただ当然ながら、同じ画面で開いている場合は、新しく開いた画面が存在しないのでwindow.close()
はエラーとなってしまいます。
なお、この問題も他のWebViewの場合でも発生します。
問題点1.3:POSTに対応できない
例えば、以下のように<form>
タグでPOSTが使われていた場合、フォームのパラメータは送信されません。
<form action="https://〜" method="post" target="_blank">...</form>
これはwebView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:
のnavigationAction
のhttpBody
が、POSTのデータがある場合でも必ずnil
となってデータが欠落してしまうからです。
ちなみにWKNavigationDelegate
のwebView:decidePolicyForNavigationAction:decisionHandler:
でも、httpBody
はnil
になります。こちらの方はバグレポート2があります。
意図的な実装なのかも知れませんが、いずれにせよどうしようもないので、この落とし穴を回避するには他の方法を採用する必要があります。
対応方法2:新しいWebViewで開く
普通のブラウザの様に新しいWebViewを作成してそこで新しいページを読み込むという方法です。
この方法であれば、
- 元の画面のページを上書きして読み込んでしまう
-
window.close()
ができない
の問題は解決されます。
実装としては、先ほどの同じWebViewで開く場合と少し違って、以下のように新しいWKWebView
を生成して返します。
func webView(_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures) -> WKWebView?
{
if navigationAction.targetFrame?.isMainFrame != true {
let newWebView = WKWebView(frame: webView.frame,
configuration: configuration)
newWebView.load(navigationAction.request)
newWebView.uiDelegate = self
webView.superview?.addSubview(newWebView)
return newWebView
}
return nil
}
この実装はサンプルなので単に同じサイズや位置で新しいWKWebView
を生成していますが、実際はちゃんとAutoLayoutの制約をはった方が良いです。
また、ポップアップのようにサイズが指定されていた場合は、windowFeatures
にサイズなどが入っていますので、これを使うこともできます。
さらに、window.close()
が呼ばれた時に正しく画面が閉じた状態となるよう、以下の実装をします。
func webViewDidClose(_ webView: WKWebView) {
webView.removeFromSuperview()
}
ただし、この方法にも2つの落とし穴があります。
問題点2.1:UIをどう実装するか
例えば、サンプルのように元のWebViewと同じ位置に同じサイズで重ねてしまうといったシンプルな実装の場合、どうやって新しい画面を閉じさせるのか?という問題が出てきます。
もし、Web画面の中に必ずwindow.close()
のボタンがあれば問題ありませんが、無い場合は閉じるボタンの様なUIをアプリ側に実装する必要があります。
当然、Safariみたく真っ当なUIを実装しようとすると、かなりの工数がかかります・・・
問題点2.2:メモリの枯渇問題
WKWebView
はそれなりにメモリを消費します。
新しく開いたWebViewの中にさらに新しい画面で開くリンクがあったりすると、延々と新しいWebViewが生成されることとなり、やがてはメモリが枯渇します。
ただ、これらの落とし穴はWeb側の作りによっては考慮しなくて良い場合もあります。ですが、多分、この方法で解決することはあまり無いと思います・・・
という訳で、残るは最後の手段です。
対応方法3:スクプリトで書き換える
WKWebView
はページのロードが完了した時に、任意のjavascriptを実行させることができます。
それで、元のページに対してスクリプトを実行し、問題となる部分を書き換える、という考え方です。
どういったスクリプトを実行させるかは、表示したいページの挙動に合わせる必要があります。
例えば、シンプルに対応する場合だと、次のような実装ができます。
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
let script = """
var elements = document.body.getElementsByTagName("*");
for (var i = 0; i < elements.length; i++) {
if (elements[i].target == '_blank') {
elements[i].target = '_self'
}
}
"""
webView.evaluateJavaScript(script, completionHandler: nil)
}
実行するスクリプトの内容は、全てのタグを調べてtarget
が_blank
なら_self
へ書き換えて新しいウィンドウを開かないようにしてしまうものです。
他にも、
-
window.close()
を書き換えて戻る動作をさせる - ポップアップのイベントを拾って処理を行う
など、Web側の実装に合わせてスクリプトを実装することができます。
(ただ、こういった実装をするぐらいなら、Web側で対応する方が良いですが・・・)
まとめ
網羅的にまとめたので長くなってしまいましたが、単に既存と合わせるだけなら対応方法1の同じWebViewで開かせる方法で十分です。
それ以上の対応が必要なら、Web側で対応してもらった方が良いです。
ですが、諸事情によりWeb側での対応ができないなら、ここに挙げた方法のどれかで解決できることを願っています・・・
-
カスタムUAを使いたいとかjavascriptを実行させたいとかPOSTを使いたいとか埋め込みのWebViewで使いたいとか・・・ ↩
-
HTTP body/bodystream (e.g., POST form data) is missing in WKNavigationDelegate callbacks ↩