WebKit - WKWebView 的兩三事

由於 UIWebView 已經過時一段時間了,Apple 建議改用 WKWebView (iOS 8+) 來實現 webview 的呈現,這篇就來簡單做個介紹跟可能會遇到的問題解法。

WKWebView 是 WebKit 的一個內建元件,可在原生 App 裡內嵌一個開啟網頁的 view,除了一般的 load(_:), reload(), goBack(_:), goForward(_:), estimatedProgress 等等之外,還可以做一些設定跟進階判斷:

  • 可設定 WKWebViewConfiguration 做更細部的設定
  • 可實作 WKUIDelegate 來實現 JS 在瀏覽器網頁的功能
    • 實現 JS alert, confirm, prompt
    • 實作 target = _blank (另開分頁) 功能
  • 規範 webview 的 WKNavigationDelegate 自訂是否要允許或拒絕導航目標 URL 的政策。
    • 自訂導航政策
    • 決策 URL 是開啟原生畫面或 webview

初始化 WKWebView

import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate {
    
    var webView: WKWebView!

    lazy var webConfiguration: WKWebViewConfiguration = {
        let pref = WKPreferences()
        pref.javaScriptEnabled = true
        let config = WKWebViewConfiguration()
        config.preferences = pref
        return config
    }()
    
    func setupWebView() {
        webView = WKWebView(frame: .zero, configuration: configuration)
        webView.uiDelegate = self
        webView.navigationDelegate = self
        self.view.addSubview(webView)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        let myURL = URL(string:"https://www.apple.com")
        let myRequest = URLRequest(url: myURL!)
        webView.load(myRequest)
    }
}

WKUIDelegate

實現 JS alert, confirm, prompt

Web 很常使用到 js 的 alert, confirm 或是 prompt 的功能,但無法觸發,因為 WKWebView 把這些事件攔截下來,傳入這個 delegate 中,讓開發者使用 App 的原生元件來進行控制

// 跳出視窗
alert("hello world");

// confirm 確認訊息
var c = confirm('Are your sure?');

// prompt 跳出輸入框對話
var p = prompt(message, default);

可實作下列 WKUIDelegate 的 methods 來實現上述的功能

處理 JS alert()

func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
    let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "確定", style: .default, handler: nil))
    self.present(alertController, animated: true, completion: nil)
    completionHandler()
}

處理 JS confirm()

// `completionHandler()` 帶 `true` 的話等是確定(OK), 帶 `false` 則是取消(Cancel)
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
    let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "確定", style: .default, handler: { (action) in
        completionHandler(true)
    }))
    alertController.addAction(UIAlertAction(title: "取消", style: .default, handler: { (action) in
        completionHandler(false)
    }))
    self.present(alertController, animated: true, completion: nil)
}

處理 JS prompt()

// webView(_:runJavaScriptTextInputPanelWithPrompt:defaultText:initiatedByFrame:completionHandler:)
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
    let alertController = UIAlertController(title: nil, message: prompt, preferredStyle: .alert)
    // 在 alertController 上加入一個 TextField
    alertController.addTextField { (textField) in
        textField.text = defaultText
    }
    alertController.addAction(UIAlertAction(title: "確定", style: .default, handler: { (action) in
        if let text = alertController.textFields?.first?.text {
            completionHandler(text)
        } else {
            completionHandler(defaultText)
        }
    }))
    alertController.addAction(UIAlertAction(title: "取消", style: .default, handler: { (action) in
        completionHandler(nil)
    }))
    self.present(alertController, animated: true, completion: nil)
}

實作 target = _blank (另開分頁) 功能

可實作 webView(_:createWebViewWith:for:windowFeatures:) 來判斷 web 的 target = _blank (另開分頁) ,接著可以自行決定是要透過原本的 webview 來繼續瀏覽目標網頁或是再另外 present 一個 view 出來

// WKNavigationDelegate
func webView(webView: WKWebView!, createWebViewWithConfiguration configuration: WKWebViewConfiguration!, forNavigationAction navigationAction: WKNavigationAction!, windowFeatures: WKWindowFeatures!) -> WKWebView! {
    // 判斷 targetFrame 是否等於 nil,若是則為另開分頁
    if navigationAction.targetFrame == nil {
        // 這邊的實作是由原本的 webview 再繼續開啟另開分頁的 URL
        webView.loadRequest(navigationAction.request)
    }
    return nil
}

WKNavigationDelegate

自訂導航政策

webView(_:decidePolicyFor:preferences:decisionHandler:) 可以用來自訂是否允許或拒絕將要瀏覽的目標 URL,若實作此 methold 請記得在特定的點執行 decisionHandler,另可判斷 preferences WKNavigationType 有些哪類型可以調用

決策 URL 是開啟原生畫面或 webview

假設有個情境是透過點擊連結之後判斷是否要由 native app 開啟或由 webview 或由 Safari browser 來開啟頁面,如果遇到自家的網域時可以開啟原生畫面,其他則是開啟 webview 或 browser

// 可先定義一個 open url type 的 enum
enum OpenUrlType {
    /// 原生頁面
    case native
    /// webview
    case webview
    /// 使用 safari browser
    case browser
}

決定開啟的方式

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: ((WKNavigationActionPolicy) -> Void)) {
    ...
    // 可透過 switch 判斷 navigationType .linkActivated 或 .other 等等
    switch navigationAction.navigationType {
    case .linkActivated: // user 點擊的連結
        // 再來取得要開啟的 OpenUrlType 是什麼
        if let type = getOpenUrlType(URL: url) {
            if type == .browser { // 使用外部 safari browser 開啟連結
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
                decisionHandler(.cancel)
                return
            } else if type == .native { // 原生頁面
                // do something
                // 做一些開啟原生畫面的實作
                decisionHandler(.cancel)
                return
            } else if type == .webview { // webview
                // 由此 webview 物件繼續瀏覽 URL
                decisionHandler(.allow)
                return
            }
        }
    case .other: // 其他
        // do something
    ...
    decisionHandler(.allow)
}

// getOpenUrlType 用來決定 url 是要透過什麼方式開啟
private func getOpenUrlType(URL url: URL) -> OpenUrlType? {
    guard
        let components = NSURLComponents(url: url as URL, resolvingAgainstBaseURL: true),
        let host = components.host,
        let pathComponents = components.path?.components(separatedBy: "/")
    else {
        return .browser
    }
    // 判斷網域是否為自家的網域
    if matchDomain(domain: host, in: ["example.com.tw", "example.tw"]) {
        if pathComponents.count >= 3 {
            // do something
            return .native
        }
        // 再自家網域內,但沒原生畫面,改為 webview 開啟
        return .webview
    }
    return .browser
}
comments powered by Disqus