WKWebViewで必要十分な機能を持ったアプリ内ブラウザを作る

ある程度使い物になる状態で `WKWebView` を組み込む時にあれこれ検索しながらコードを書いたのでまとめます。

ドキュメントは[WKWebView - WebKit | Apple Developer Documentation](https://developer.apple.com/documentation/webkit/wkwebview)

## 環境
Swift 5.0
iOS 12.2
Xcode Version 10.2.1



## WKWebViewの基本
アプリ内ブラウザのような、interactive なウェブコンテンツを表示するためのオブジェクト。
`WKWebView`オブジェクトを作成し、それをビューとして設定して、Webコンテンツをロードする要求を行えばウェブコンテンツが描画されます。
以前は UIWebView が使用されていましたが、現在は WKWebView を使用することが推奨されています。

WKWebView にはローカルHTMLファイルの読み込みを開始する`loadHTMLString（_：baseURL :)`、Webコンテンツの読み込みを開始する`load（_ :)`、loadingを停止する`stopLoading()`などがあります。
デリゲートプロパティを`WKUIDelegate`プロトコルに準拠するオブジェクトに設定して、Webコンテンツの読み込みを追跡できます。

WKWebView を実装してみます。コードでの実装は[公式レファレンスのListing 1 Creating a WKWebView　programmatically](https://developer.apple.com/documentation/webkit/wkwebview)にあります。

今回はコンテナとして置いた UIView の中に WKWebView を addSubView して実装してみます。

```swift
import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate {
    @IBOutlet weak var containerView: UIView!
    var webView: WKWebView!
  
    override func loadView() {
        super.loadView()
        let webConfiguration = WKWebViewConfiguration()
        webView = WKWebView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height), configuration: webConfiguration)
        webView.uiDelegate = self
        containerView.addSubview(webView)
        // 制約
        webView.topAnchor.constraint(equalToSystemSpacingBelow: containerView.topAnchor, multiplier: 0.0).isActive = true
        webView.bottomAnchor.constraint(equalToSystemSpacingBelow: containerView.bottomAnchor, multiplier: 0.0)
        webView.leadingAnchor.constraint(equalToSystemSpacingAfter: containerView.leadingAnchor, multiplier: 0.0)
        webView.trailingAnchor.constraint(equalToSystemSpacingAfter: containerView.trailingAnchor, multiplier: 0.0)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        guard let url = URL(string: "https://www.apple.com") else { fatalError() }
        let request = URLRequest(url: url)
        webView.load(request)
    }
}
```

特定のサイトを表示するだけならこの実装で良いかもしれません。今回は様々なサイトを表示する可能性がある簡易なアプリ内ブラウザとして、実装してみます。

## アプリ内ブラウザとして必要そうな機能を実装する

### 戻る・進む
戻るボタンを進むボタンを実装します。

### goBack()とgoForward()
適当にUIButtonを2つ置いてIBActionでコードと紐づけます。

```swift
@IBAction func tappedBackButton(_ sender: UIButton) {
        webView.goBack()
    }
    
@IBAction func tappedForwardButton(_ sender: UIButton) {
        webView.goForward()
    }
```

### 戻れない時、進めない時にUIButtonを無効化する
タップせずとももう戻れない or 進めないのを判断したいです。そこでまず storyBoard の UIButton 2つをコードにIBOutletで紐づけます。
webView にコンテンツがロードされていく過程での振る舞いをカスタマイズしたいので、ViewController を WKNavigationDelegate に conform させて、webViewのnavigationDelegateプロパティにViewController自身をを代入します。
WKNavigationDelegateにcoformしたことによって、navigation requestが完了した時に呼ばれるwebView(_:didFinish:)が実装できるようになるのでここで戻るボタン、進むボタンの有効化 or 無効化を行います。

```swift
override func loadView() {
        super.loadView()
        webView.navigationDelegate = self
    }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  DispatchQueue.main.async {
    self.backButton.isEnabled = webView.canGoBack
    self.backButton.alpha = webView.canGoBack ? 1.0 : 0.4
    self.forwardButton.isEnabled = webView.canGoForward
    self.forwardButton.alpha = webView.canGoForward ? 1.0 : 0.4
    }
  }
```

この実装方法でも一応動くのですが、webView(_:didFinish:)が実行されるまで、つまりロードが完了するまではbuttonの状態が変化しません。そのため初期状態ではisEnableをfalseに、alphaを0.4にして`canGoBack` と`canGoForward`を監視して変更のたびにボタンの状態を変更するように実装します。
まず上のコードの内、webView(_:didFinish:)を削除します。両ボタンの初期状態はstoryBoardで設定してもdidSet等で設定してもどちらでも問題ないと思いますが、今回はdidSetで行うことにします。
値の監視はKVO(key value observing)という仕組みを使います。プロパティの値の変化を通知してくれます。canGoBack と canGoForward の値の変化が通知されたらボタンの状態を変化させます。


```swift
    @IBOutlet weak var backButton: UIButton! {
        didSet {
            backButton.isEnabled = false
            backButton.alpha = 0.4
        }
    }
    @IBOutlet weak var forwardButton: UIButton! {
        didSet {
            forwardButton.isEnabled = false
            forwardButton.alpha = 0.4
        }
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        _observers.append(webView.observe(\.canGoBack, options: .new){ _, change in
            if let value = change.newValue {
                 DispatchQueue.main.async {
                    self.backButton.isEnabled = value
                    self.backButton.alpha = value ? 1.0 : 0.4
                }
            }
        })
        
        _observers.append(webView.observe(\.canGoForward, options: .new){ _, change in
            if let value = change.newValue {
                 DispatchQueue.main.async {
                self.forwardButton.isEnabled = value
                self.forwardButton.alpha = value ? 1.0 : 0.4
                }
            }
        })
    }
```

### 横スワイプのジェスチャで戻れる or 進めるようにする
一行で終わりです。

```swift
    override func loadView() {
        super.loadView()
        webView.allowsBackForwardNavigationGestures = true
        // 他の実装
    }
```

終わりなんですが、これは初期ページに WebView を表示させているだけのアプリなので問題ないだけで、 TableView を使って表示させている記事リストをタップして`pushViewController:animated`を使って遷移させた時などは、履歴で見てもう戻る先のURLがない時のみ navigationController の左スワイプを優先したいです。
その場合は navigationController の`interactivePopGestureRecongnizer`に`UIGestureRecognizerDelegate`に conform させてViewController 自身をを代入します。
conform した場合ジェスチャ開始通知を`gestureRecognizerShouldBegin(_:)`メソッドで検知できるのでここで戻る履歴がある場合のみ`goBack()`が優先されるよう実装します。


```swift
override func loadView() {
        super.loadView()
        webView.allowsBackForwardNavigationGestures = true
        self.navigationController?.interactivePopGestureRecongnizer?.delegate = self
        // 他の実装
    }

extension ViewController:  UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        // webView.goBack()を優先したい
        return !webView.canGoBack
    }
}
```
ここまで実装しておいてなんですが、正直前後に遷移できるボタンがあるのでスワイプを有効にしない方が良いのではとも思います……

### ヘッダーにタイトルの表示と再読込ボタンの設置
KVO を使って実装します。 WKWebView クラスの title プロパティを監視してタイトルのヘッダーへの反映を、再読込は isLoading プロパティを監視して更新できる状態のみ更新ボタンをタップできるよう実装します。

```swift
    @IBOutlet weak private var titleLabel: UILabel!
    @IBOutlet weak var reloadButton: UIButton!
    @IBAction func tappedReloadButton(_ sender: Any) {
        webView.reload()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // 既存の実装
        _observers.append(webView.observe(\.isLoading, options: .new) {_, change in
            if let value = change.newValue {
                DispatchQueue.main.async {
                    // isLoadingがtrueのときは更新できないようにしたい
                    self.reloadButton.isEnabled = !value
                    self.reloadButton.alpha = !value ? 1.0 : 0.4
                }
            }
        })
        
        _observers.append(webView.observe(\.title, options: .new) {_, change in
            if let value = change.newValue {
                DispatchQueue.main.async {
                    self.titleLabel.text = value
                }
            }
        })
    }   
```


### 読み込みの現在の状態を示すインジケータの表示
あとどれぐらいの時間読み込みにかかるのかある程度予想がつくようにインジケータを表示したいです。
WKWebView の estimateProgress を監視して UIProgressView の状態を更新していきます。

```swift
func setUpProgressView() {
        self.progressView = UIProgressView(frame: CGRect(
            x: 0,
            y: headerView.frame.maxY,
            width: self.view.frame.width,
            height: 3.0))
        self.progressView.progressViewStyle = .bar
        self.view.addSubview(self.progressView)
        _observers.append(self.webView.observe(\.estimatedProgress, options: .new, changeHandler: { (webView, change) in
            self.progressView.alpha = 1.0
            // estimatedProgressが変更された時にプログレスバーの値を変更
            self.progressView.setProgress(Float(change.newValue!), animated: true)
            if self.webView.estimatedProgress >= 1.0 {
                UIView.animate(withDuration: 0.3,
                               delay: 0.3,
                               options: [.curveEaseOut],
                               animations: { [weak self] in
                                self?.progressView.alpha = 0.0
                    }, completion: {_ in
                        self.progressView.setProgress(0.0, animated: false)
                })
            }
        })
        )
    }

   override func loadView() {
        super.loadView()
        
        let webConfiguration = WKWebViewConfiguration()
        webView = WKWebView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height), configuration: webConfiguration)
        // 他の実装
        setUpProgressView()
        
    }
```

### JavaScriptの alert, confirm, promptを表示させる
デフォルトだと JavaScript の alert 、confirm 等を実行すると WKUIDelegate のデリゲートメソッドが走ります。何も実装しないと何も反応しません。
押したタイミングで Swift 側から alert を表示して本来 JavaScript 側で表示させているダイアログのように見せます。

```swift
extension ViewController: WKUIDelegate {
    func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
        // alert対応
        let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
        let action = UIAlertAction(title: "OK", style: .default) { _ in
            completionHandler()
        }
        alertController.addAction(action)
        present(alertController, animated: true, completion: nil)
    }
    
    func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
        // confirm対応
        let alertController = UIAlertController(title: "", message: message, preferredStyle: .alert)
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
            completionHandler(false)
        }
        let okAction = UIAlertAction(title: "OK", style: .default) { _ in
            completionHandler(true)
        }
        
        alertController.addAction(cancelAction)
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }
    
    func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
        // prompt対応
        let alertController  = UIAlertController(title: "", message: prompt, preferredStyle: .alert)
        let okHandler: () -> Void = {
            if let textField = alertController.textFields?.first {
                completionHandler(textField.text)
            } else {
                completionHandler("")
            }
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
            completionHandler(nil)
        }
        let okAction = UIAlertAction(title: "OK", style: .default) { _ in
            okHandler()
        }
        alertController.addTextField { $0.text = defaultText }
        alertController.addAction(cancelAction)
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }

    // 以下他の実装
}
```

### Basic認証対応 & SSL/TLSのhttps通信制御
Basic等の認証を要求するサイトにアクセスできるようにするには`webView(_:didReceive:completionHandler:)`という WKNavigationDelegate のデリゲートメソッドが必要になります。またこのデリゲートメソッドを実装した場合、デフォルトで勝手にやってくれる TLS/SSL 通信時の制御も自分で行う必要があります。
この辺りの情報は[@niwatako](https://qiita.com/niwatako)さんの[iOS9からWKWebViewのSSL/TLS接続はコードで制御する](https://qiita.com/niwatako/items/9ae602cb173625b4530a)、[iOS の通信における認証の種類とその取り扱い](https://www.slideshare.net/niwatako/ios-58383178)等の記事、スライドが大変わかりやすかったです。
少し長い実装ですが、接続方法、認証方法ごとにハンドリングして個別に処理しているだけの内容です。


```swift
    // 認証対応
    func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        print("webView:didReceive challenge: completionHandler called.")
        // SSL/TLS接続ならここで処理する
        if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
            guard let serverTrust = challenge.protectionSpace.serverTrust else {
                completionHandler(.rejectProtectionSpace, nil)
                return
            }
            
            var trustResult = SecTrustResultType.invalid
            guard SecTrustEvaluate(serverTrust, &trustResult) == noErr else {
                completionHandler(.rejectProtectionSpace, nil)
                return
            }
            switch trustResult {
            case .recoverableTrustFailure:
                print("Trust failed recoverably")
                // Safariのような認証書のエラーが出た時にアラートを出してそれでも信頼して接続する場合は続けるをタップしてください -> タップされたら強制的に接続のような実装はここで行う。
                return
            case .fatalTrustFailure:
                completionHandler(.rejectProtectionSpace, nil)
                return
            case .invalid:
                completionHandler(.rejectProtectionSpace, nil)
                return
            case .proceed:
                break
            case .deny:
                completionHandler(.rejectProtectionSpace, nil)
                return
            case .unspecified:
                break
            default:
                break
            }
            
        } else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic
            || challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPDigest
            || challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodDefault
            || challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodNegotiate
            || challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodNTLM {
            // Basic認証等の対応
            let alert = UIAlertController(
                title: "認証が必要です",
                message: "ユーザー名とパスワードを入力してください",
                preferredStyle: .alert
            )
            alert.addTextField(configurationHandler: {(textField: UITextField!) -> Void in
                textField.placeholder = "user name"
                textField.tag = 1
            })
            alert.addTextField(configurationHandler: {(textField: UITextField!) -> Void in
                textField.placeholder = "password"
                textField.isSecureTextEntry = true
                textField.tag = 2
            })
            
            let okAction = UIAlertAction(title: "ログイン", style: .default, handler: { _ in
                var user = ""
                var password = ""
                
                if let textFields = alert.textFields {
                    for textField in textFields {
                        if textField.tag == 1 {
                            user = textField.text ?? ""
                        } else if textField.tag == 2 {
                            password = textField.text ?? ""
                        }
                    }
                }
                
                let credential = URLCredential(user: user, password: password, persistence: URLCredential.Persistence.forSession)
                completionHandler(.useCredential, credential)
            })
            
            let cancelAction = UIAlertAction(title: "キャンセル", style: .cancel, handler: { _ in
                completionHandler(.cancelAuthenticationChallenge, nil)
            })
            alert.addAction(okAction)
            alert.addAction(cancelAction)
            present(alert, animated: true, completion: nil)
            return
        }
        completionHandler(.performDefaultHandling, nil)
    }
```

### target="_blank"への対応
今回はアプリ内ブラウザを想定しているので、

- target="_blank"のリンクをタップしたら現在のwebViewで開く
- Appstoreへのリンク、他のアプリへのURL Scheme対応

などが可能なように実装します。

```swift
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {

        
        guard let url = navigationAction.request.url else {
            return nil
        }
        
        if url.absoluteString.range(of: "//itunes.apple.com/") != nil {
            if UIApplication.shared.responds(to: #selector(UIApplication.open(_:options:completionHandler:))) {
                UIApplication.shared.open(url, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly: false], completionHandler: { (finished: Bool) in
                    
                })
            } else {
                UIApplication.shared.open(url)
                return nil
            }
        } else if !url.absoluteString.hasPrefix("http://") && !url.absoluteString.hasPrefix("https://") {
            if UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url)
                return nil
            }
        }
        
        // target="_blank"のリンクを開く
        guard let targetFrame = navigationAction.targetFrame, targetFrame.isMainFrame else {
            webView.load(URLRequest(url: url))
            return nil
        }
        return nil
    }
```

コミット
[Add webView(_:createWebViewWith:for:windowFeatures:) method · Nabeatsu/WKWebViewSample@0e23343](https://github.com/Nabeatsu/WKWebViewSample/commit/0e23343ced40a34a9c921db204c39cd22770a8db)

※この実装方法ではメインフレームでリクエストを投げて別ウインドウで他のページを開こうとする場合に[問題](https://qiita.com/ShingoFukuyama/items/b3a1441025a36ab7659c#comment-0c365eb60362e45d004c)が生じます。どのように問題を解決するのかによってこの後の実装が変わってきます(新しいwebViewを用意して開くようにする等)が、まだ個人的にもどうするか決められなかったのでここまでの実装としました。

参考:[WKWebViewで新しいウィンドウ（タブ）を開く - Qiita](https://qiita.com/m__ike_/items/f2f9edf1dd9dfad70f13#_reference-f5eaa14c53c19a17160c)

### 下にスクロールした時にヘッダーを非表示にして表示領域を広げたい
ニュースアプリのアプリ内ブラウザの様に下にスクロールしたらヘッダーを隠して上にスクロールしたらヘッダーがまた表示されるのを実装します。
今回はUIViewでヘッダーを作っているので制約の constant プロパティを変化させる単純な実装で作ります。navigationBarを使用している場合は`setToolbarHidden:animated:`を使用します。

```swift 
class ViewController: UIViewController {
    var beginingPoint: CGPoint!
    var isViewShowed: Bool!

    override func viewDidLoad() {
        super.viewDidLoad()
        isViewShowed = false
        beginingPoint = CGPoint(x: 0, y: 0)
        // 他の実装
    }
}

// スクロール領域拡大のためにconform
extension ViewController: UIScrollViewDelegate {
    @IBOutlet weak var headerHeightConstraint: NSLayoutConstraint!
    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        beginingPoint = scrollView.contentOffset
    }
    
    override func viewDidAppear(_ animated: Bool) {
        isViewShowed = true
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let currentPoint = scrollView.contentOffset
        let contentSize = scrollView.contentSize
        let frameSize = scrollView.frame
        let maxOffset = contentSize.height - frameSize.height
        
        if currentPoint.y >= maxOffset {
            // print("hit the bottom")
        } else if beginingPoint.y + 100 < currentPoint.y {
            self.headerHeightConstraint.constant = 0.0
            headerView.alpha = 0.0
            UIView.animate(withDuration: 0.3) {
                self.view.layoutIfNeeded()
            }
        } else {
            self.headerHeightConstraint.constant = 50.0
            headerView.alpha = 1.0

            UIView.animate(withDuration: 0.3) {
                self.view.layoutIfNeeded()
            }
        }
        
    }
}
```

## コード
サンプルはGitHubに置いてます。
[Nabeatsu/WKWebViewSample](https://github.com/Nabeatsu/WKWebViewSample)

## まとめ
誤りやより適切な書き方があるかもしれません。なにかお気づきの際はコメントにてご指摘いただけると幸いです。