Une WKWebView compatible avec macOS El Capitan et Sierra

J'ai récemment découvert à mes dépens qu'une WKWebView créée avec Interface Builder dans Xcode 8.3 marche très bien dans macOS Sierra (10.12) mais crashe abominablement dans macOS El Capitan (10.11).

En effet, le composant WKWebView utilisable dans l'Interface Builder de Xcode 8.3 n'existe pas dans El Capitan… mais Xcode ne le dit pas.

Quand votre app est lancée dans Sierra, le xib est instancié, mais pas dans El Capitan, ce qui entraîne un crash.

Voici la solution.

Le principe

La solution est de ne pas utiliser Interface Builder mais de créer la fenêtre et sa vue par code, en utilisant des subclass de NSWindowController, NSViewController, et WKWebView.

On assigne la vue de la WKWebView à la vue de la subclass du NSViewController, échappant ainsi à la nécéssité de passer par les composants d'Interface Builder dans Xcode.

Car une WKWebView instanciée par code fonctionne très bien dans les deux OS - c'est la version créée par Interface Builder qui est buggée.

Créer un NSWindowController

Créons tout d'abord la fenêtre qui accueillera la vue.

Faites “New > File” et choisissez “Cocoa Class”, et créez une subclass de NSWindowController, nommée “AYAWebWindow” dans mon exemple. Cochez la case “XIB” (c'est important, sinon ça ne marchera pas).

Dans votre nouvelle subclass, faites un override de windowNibName avec le nom de votre classe, et créez un convenience init qui prend une URL en paramètre :

class AYAWebWindow: NSWindowController {

    override var windowNibName: String! {
        return "AYAWebWindow"
    }

    convenience init(url: URL) {
        self.init(window: nil)
    }

}

Le fait de créer un convenience init nous “oblige” à signaler le nom de la classe associée à la fenêtre dans windowNibName - mais c'est exactement ce que l'on veut, ne pas utiliser Interface Builder et créer la fenêtre nous-même manuellement.

Créer un NSViewController

Faites de nouveau “New > File” et choisissez “Cocoa Class”, et cette-fois créez une subclass de NSViewController, également avec “XIB”, nommée “AYAWebView” dans mon exemple.

Dans ce view controller, créez un convenience init qui prend une URL en paramètre et l'attribue à une variable :

class AYAWebView: NSViewController {

    var url: URL!

    convenience init(url: URL) {
        self.init()
        self.url = url
    }

}

Préparer le NSWindowController

Et maintenant instanciez le view controller à partir du window controller :

class AYAWebWindow: NSWindowController {

    var webVC: APWebViewController!

    override var windowNibName: String! {
        return "AYAWebWindow"
    }

    convenience init(url: URL) {
        self.init(window: nil)
        self.webVC = APWebViewController(url: url)
    }

}

Voilà pour le principe de base.

Ajouter la WKWebView dans le NSViewController

Maintenant il faut ajouter la WKWebView à notre view controller, et remplacer la vue du controller par la vue de la WKWebView.

C'est là toute l'astuce !

import WebKit

class AYAWebView: NSViewController {

    var webView: WKWebView!
    var url: URL!

    override func loadView() {
        webView = WKWebView()
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        webView.load(URLRequest(url: url))
    }

    convenience init(url: URL) {
        self.init()
        self.url = url
    }

}

Assigner la WKWebView dans la fenêtre

Nous y sommes presque.

Pour afficher correctement cette vue, j'ai décidé d'ajouter une Custom View à la window et d'y attribuer la vue web, simplement attachée aux bords de la fenêtre par AutoLayout comme ceci :

class AYAWebWindow: NSWindowController {

    @IBOutlet weak var targetView: NSView!
    var webVC: APWebViewController!

    override var windowNibName: String! {
        return "AYAWebWindow"
    }

    convenience init(url: URL) {
        self.init(window: nil)
        self.webVC = APWebViewController(url: url)
    }

    override func windowDidLoad() {
        super.windowDidLoad()
        webVC.view.setFrameSize(targetView.frame.size)
        webVC.view.setBoundsSize(targetView.bounds.size)
        webVC.view.translatesAutoresizingMaskIntoConstraints = false
        targetView.addSubview(webVC.view)
        targetView.trailingAnchor.constraint(equalTo: webVC.view.trailingAnchor).isActive = true
        targetView.topAnchor.constraint(equalTo: webVC.view.topAnchor).isActive = true
        targetView.bottomAnchor.constraint(equalTo: webVC.view.bottomAnchor).isActive = true
        targetView.leadingAnchor.constraint(equalTo: webVC.view.leadingAnchor).isActive = true
    }

}

Résultat

Terminé !

Cette WKWebView marche dans 10.11 et 10.12 de la même façon.

On peut instancier la fenêtre et la montrer, par exemple, à partir de AppDelegate :

var webWC: AYAWebWindow?

func applicationDidFinishLaunching(_ notification: Notification) {
    webWC = AYAWebWindow(url: <votre URL>)
    webWC?.window?.center()
    webWC?.window?.makeKeyAndOrderFront(self)
}

Navigation

Pour améliorer tout ceci, vous pouvez par exemple mettre le view controller comme délégué de la navigation pour la vue web, et utiliser didFinish navigation pour ajouter des actions quand l'utilisateur navigue sur le web :

class AYAWebView: NSViewController {

    var url: URL!
    var webView: WKWebView!

    override func loadView() {
        webView = WKWebView()
        webView.navigationDelegate = self
        view = webView
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        webView.load(URLRequest(url: url))
    }

    convenience init(url: URL) {
        self.init()
        self.url = url
    }

}

extension AYAWebView: WKNavigationDelegate {

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        if webView.canGoBack {
            // montrer un bouton "Retour"
        } else {
            // cacher le bouton "Retour"
        }
    }

}

Si vous ajoutez un bouton “retour” vous pouvez forcer la WKWebView à revenir à l'URL d'origine comme ceci :

func backButton() {
    if let item = webView.backForwardList.backList.first {
        webView.go(to: item)
    } else {
        webView.load(url)
    }
}

On force le retour, sinon on force de nouveau l'URL.

Dans les deux cas on va récupérer les cookies actifs pour le domaine. Ca peut être pratique, mais si vous ne voulez pas ça, il faut les supprimer manuellement avant de charger la page :

func destroyCookies() {
    let dataStore = WKWebsiteDataStore.default()
    let types = WKWebsiteDataStore.allWebsiteDataTypes()
    dataStore.fetchDataRecords(ofTypes: types) { (records) in
        for record in records {
            if record.displayName.contains(<éléments_de_votre_domaine>) {
                dataStore.removeData(ofTypes: types, for: [record], completionHandler: {
                    print("Deleted: " + record.displayName)
                })
            }
        }
    }
}

Pour finir, notez que WKWebView est “released” (détruit) dès que la fenêtre est fermée.

Si vous la rouvrez directement sans rien faire vous aurez un crash. Il faut soit la recréer complètement (comme vu dans le AppDelegate), soit juste réinstancier la vue web :

if webWC?.webVC.webView == nil {
    webWC?.webVC.loadView()
}
Auteur: Eric Dejonckheere