Edited at

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

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

ドキュメントはWKWebView - WebKit | Apple Developer Documentation


環境

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にあります。

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

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でコードと紐づけます。

@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 無効化を行います。

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にしてcanGoBackcanGoForwardを監視して変更のたびにボタンの状態を変更するように実装します。

まず上のコードの内、webView(
:didFinish:)を削除します。両ボタンの初期状態はstoryBoardで設定してもdidSet等で設定してもどちらでも問題ないと思いますが、今回はdidSetで行うことにします。

値の監視はKVO(key value observing)という仕組みを使います。プロパティの値の変化を通知してくれます。canGoBack と canGoForward の値の変化が通知されたらボタンの状態を変化させます。

    @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 進めるようにする

一行で終わりです。

    override func loadView() {

super.loadView()
webView.allowsBackForwardNavigationGestures = true
// 他の実装
}

終わりなんですが、これは初期ページに WebView を表示させているだけのアプリなので問題ないだけで、 TableView を使って表示させている記事リストをタップしてpushViewController:animatedを使って遷移させた時などは、履歴で見てもう戻る先のURLがない時のみ navigationController の左スワイプを優先したいです。

その場合は navigationController のinteractivePopGestureRecongnizerUIGestureRecognizerDelegateに conform させてViewController 自身をを代入します。

conform した場合ジェスチャ開始通知をgestureRecognizerShouldBegin(_:)メソッドで検知できるのでここで戻る履歴がある場合のみgoBack()が優先されるよう実装します。

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 プロパティを監視して更新できる状態のみ更新ボタンをタップできるよう実装します。

    @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 の状態を更新していきます。

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 側で表示させているダイアログのように見せます。

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さんのiOS9からWKWebViewのSSL/TLS接続はコードで制御するiOS の通信における認証の種類とその取り扱い等の記事、スライドが大変わかりやすかったです。

少し長い実装ですが、接続方法、認証方法ごとにハンドリングして個別に処理しているだけの内容です。

    // 認証対応

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対応

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

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

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

参考:WKWebViewで新しいウィンドウ(タブ)を開く - Qiita


下にスクロールした時にヘッダーを非表示にして表示領域を広げたい

ニュースアプリのアプリ内ブラウザの様に下にスクロールしたらヘッダーを隠して上にスクロールしたらヘッダーがまた表示されるのを実装します。

今回はUIViewでヘッダーを作っているので制約の constant プロパティを変化させる単純な実装で作ります。navigationBarを使用している場合はsetToolbarHidden:animated:を使用します。

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


まとめ

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