WKWebViewで新しいウィンドウ（タブ）を開く

ついにというかようやくというか`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`を使う方が良いです。

とは言っても、そうもいかないこともある[^2]ので、その場合は実装で対応することになります。。。


[^2]: カスタムUAを使いたいとかjavascriptを実行させたいとかPOSTを使いたいとか埋め込みのWebViewで使いたいとか・・・

### 対応方法１：同じWebView内で開く

最も一般的な方法は`UIWebView`の挙動を踏襲して、新しい画面で開くページを元の画面と同じWebViewでそのまま開くというものです。

（Qiitaでも[WKWebViewでtarget="_blank"なリンクが開かない時の対処法](https://qiita.com/ShingoFukuyama/items/b3a1441025a36ab7659c)という記事があります）

実装としては、`WKUIDelegate`の`webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:`を以下のように実装し、`WKWebView`の`uiDelegate`にセットします。

```swift
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：元の画面のページを上書きして読み込んでしまう  

先ほどの記事の[コメント欄](https://qiita.com/ShingoFukuyama/items/b3a1441025a36ab7659c#comment-0c365eb60362e45d004c)で指摘されている点です。

> target="_blank" を強引にメインのwebViewにロードさせる場合は注意が必要です。
>
> 例えばイベントサイトのATNDで参加登録をした時、
> メインフレームで参加登録リクエストが投げられ、別ウィンドウでTwitterへの投稿画面が開かれようとします。
> 
> このとき、別ウィンドウで開かれようとしているTwitterへの投稿画面を、
> 参加登録リクエストを送信するはずだったwebViewに読み込ませてしまうと、
> 肝心の参加登録処理が実行されないままになってしまいます。

なお、この問題は他のWebViewの場合でも発生します。

#### 問題点1.2：`window.close()`ができない

よくあるポップアップ画面だと、元の画面に戻りやすい様に「閉じる」ボタンがついています。そして「閉じる」をタップするとjavascriptで`window.close()`を呼び出し、新しく開いた画面を閉じる仕組みになっています。

ただ当然ながら、同じ画面で開いている場合は、新しく開いた画面が存在しないので`window.close()`はエラーとなってしまいます。

なお、この問題も他のWebViewの場合でも発生します。

#### 問題点1.3：POSTに対応できない

例えば、以下のように`<form>`タグでPOSTが使われていた場合、フォームのパラメータは送信されません。

```html
<form action="https://〜" method="post" target="_blank">...</form>
```

これは`webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:`の`navigationAction`の`httpBody`が、POSTのデータがある場合でも必ず`nil`となってデータが欠落してしまうからです。

ちなみに`WKNavigationDelegate`の`webView:decidePolicyForNavigationAction:decisionHandler:`でも、`httpBody`は`nil`になります。こちらの方はバグレポート[^3]があります。


[^3]: [HTTP body/bodystream (e.g., POST form data) is missing in WKNavigationDelegate callbacks](https://bugs.webkit.org/show_bug.cgi?id=140188)

意図的な実装なのかも知れませんが、いずれにせよどうしようもないので、この落とし穴を回避するには他の方法を採用する必要があります。


### 対応方法2：新しいWebViewで開く

普通のブラウザの様に新しいWebViewを作成してそこで新しいページを読み込むという方法です。

この方法であれば、

- 元の画面のページを上書きして読み込んでしまう
- `window.close()`ができない

の問題は解決されます。

実装としては、先ほどの同じWebViewで開く場合と少し違って、以下のように新しい`WKWebView`を生成して返します。

```swift
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()`が呼ばれた時に正しく画面が閉じた状態となるよう、以下の実装をします。

```swift
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を実行させることができます。
それで、元のページに対してスクリプトを実行し、問題となる部分を書き換える、という考え方です。

どういったスクリプトを実行させるかは、表示したいページの挙動に合わせる必要があります。

例えば、シンプルに対応する場合だと、次のような実装ができます。

```swift
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側での対応ができないなら、ここに挙げた方法のどれかで解決できることを願っています・・・