WKWebView and WKCookieStore in iOS 11

Today I am going to talk about the new WebkitView in iOS 11 and about our love/hate relationship.

The project I worked on was a simple iOS application which should provide a native component for login/signup, navigation, and use a web view to display his website and all the other features which were provided via the web page.

At first this task seems trivial and most probably there are many of you who already did it. However, not many of us implemented this on the iOS 11, using the latest tools provided by Apple and here I’m referring to the WKWebView with all its additional properties.

What I’m going to expose here are some least know features, or not clear from Apple’s documentation, or things we’ve struggled with while implementing this project.

The task is simple, set cookies on a webpage (so we know that the user is logged in), do some CSS manipulation on all pages (basically hide the navigation bar of the site and some other components) and add some custom JS so we know when the user navigates within an iframe.

So lets get started with it 💻

Cookies

The first task is to add some cookies to the WKWebView so when we fire any request to have those in place. Previous to iOS 11, the cookies used by the app would be shared and stored in the NSHTTPCookieStorage . UIWebView would use cookies from this cookie storage (parent app cookie storage).

All of those has changed with the introduction of WKHTTPCookieStore. Starting with iOS 8 we have a new component for displaying web content in our iOS apps, namely WKWebView.

iOS 11 came with some changes and added some extra features to the WKWebView, basically each WKWebView now has its own cookie storage and its not share by the whole app and all other web views like in the case of (UIWebView).

To add cookies to a WKWebview we can use the cookie store WKHTTPCookieStore which has a set of methods for cookie manipulation, setCookiegetAllCookies and delete cookie.

All of those methods work with a completion handler which gets called after the operation was made. All those operations are async operations and SHOULD BE CALLED FROM THE MAIN THREAD. The documentation for this is kind of thin and we haven’t found it to be written anywhere.

All those methods work with objects of HTTPCookie objects. The initialisation of those objects is pretty straight forward and we won’t cover it.

Another interesting thing we found is that those calls don’t always succeed, and what I mean by that is that the completion handler is not always called, event if those methods are called from the Main thread and this is not blocked by any operation.

We found this behaviour to be pretty odd and we couldn’t find any documentation for it as well. This approach worked for when we first displayed the WKWebView, but when we would display it the second or third time, the setting of the cookies would fail.

Initially this broke our code as we relied on the completion handler of the setCookie to be called at some point, but it didn’t so after a few hours of trying different approaches and scrapping the web for answers we have found a StackOverflow hint.

The solution was to use a shared WKProcessPool object between all the WKWebViews used in the app. WKProcessPool states where the cookies should be saved, so by using the same pool, we have found out that the get, save and delete methods on the shared cookie storage work properly.

Hope this will save you some time. We found out that Apple’s documentation in are of WebKit is pretty scarce and thats is the main reason why we have a love/hate relationship.

CSS manipulation

In the app we build the target was from start iOS 11, which meant we could use all the new 💎 things (WKWebView and WKHTTPCookieStore).

First of we have to initialise the WKWebView before we start using it, in order to do that we would need an instance of WKWebViewConfiguration, which is a collection of properties used for configuring the WKWebView. This configuration is responsible for determining how soon a page is rendered and many other options.

This configuration object will be used for CSS manipulation. We want some CSS to be applied to the web pages before they are displayed on the screen, we want the header of our website stripped hidden in the web view presented in the app.

There are many properties on this configuration object, but the one we are most interested in for fulfilling the above stated task is the .userContentController property. WKUserContentController is an object which allows the user to inject JavaScript code in the web view.

The whole idea is the following, write some JS script that will append a CSS to the current HTML document structure and execute that when the web view loads.

The JS script for achieving this is the following:

var styleTag = document.createElement("style");
styleTag.textContent = 'header { display: none;} ';

document.documentElement.appendChild(styleTag);

We create a new style tag, we populate it with our custom CSS, in this example “header { display: none;}” for hiding the header element. You can place here any CSS code, in order to fulfil your desired behaviour. The last line adds the tag as a child tag to the current HTML document.

Now that we have all the elements, lets stick them together, we need to initialise a WKWebView, add the script and load a page.

The following code, does exactly that:


        let wkWebConfig = WKWebViewConfiguration() // We create a web view configuration
        let wkUserController = WKUserContentController() // We create a user content controller for adding the JS for manipulating the CSS of the web page
        
        wkWebConfig.userContentController = wkUserController
        
        addHideHeaderScript(wkWebController: wkUserController) // We add the the hide header script
	// We configure the web view, and add it as a subview to the current view
        webView = WKWebView(frame: self.view.bounds, configuration: wkWebConfig)
        webView.navigationDelegate = self // adding the delegate for receiving navigation callbacks (weather a page has loaded or an error occured)
        
        view.addSubview(webView)
        
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }

    private func addHideHeaderScript(wkWebController: WKUserContentController) {
        // Add hide header bar script
        let scriptURL = Bundle.main.path(forResource:”name_of_the_script_file”, ofType: "js") // finds the path for the JS file
        let scriptContent = try! String.init(contentsOfFile: scriptURL!) // loads the content of the file into a script
	// Creates a WKUserScript object, with the contents of the .js file, and injects it .atDocumentStart 
	// (injects the script after the document element is created, but before any other content is loaded)
	// the element should be applied to all frames of the web page not only to the main one
        let script = WKUserScript(source: scriptContent, injectionTime: .atDocumentStart, forMainFrameOnly: false)
        
	// add the user defined script to the user content controller of our WKWebViewConfig
        wkWebController.addUserScript(script)
    }

After calling the “configureWebView” method, the we can start loading pages using the load method of the WKWebView.

JS callbacks in Swift

The next step it is to add the a script which would notify us when a page on an iframe changes. Unfortunately the .navigationDelegate of the WKWebView doesn’t notify us regarding the changes in the iframes of a web page.

To achieve this behaviour we will use once again JavaScript, the process is similar to the one used before, but also has a little twist. We want a Swift callback every time the web page navigates to a new link, previously we have seen how we inject JavaScript into a web page (Swift -> JavaScript), but we haven’t talked about the backwards communication, JavaScript -> Swift.

In order to receive callbacks from JavaScript we need to implement the WKScriptMessageHandler protocol which has only one method

func userContentController(WKUserContentController, didReceive: WKScriptMessage)

This will be called every time our JS code posts a message back to the Swift code.

So how do we use it?

The process is very similar to the one used before

  1. Create a .js file for the JS code and populate it with the custom code which will notify the Swift code base when a certain event occurs
window.onhashchange = function () { window.webkit.messageHandlers.notification_name.postMessage('pageHasChanged');}

The previous code does the following, every time the window has change function is called from the HTML we post a message to the webkit with the identifier “notification_name” and some message, in this example “pageHasChanged”,

2. Add the JS and the delegate

 private func addPageHasChangedDelegateScript(wkWebController: WKUserContentController) {
        // load the script from the file and create a WKUserScript object
        let scriptURL = Bundle.main.path(forResource: Constants.detectPageChangeScript, ofType: "js")
        let scriptContent = try! String.init(contentsOfFile: scriptURL!)
        let script = WKUserScript(source: scriptContent, injectionTime: .atDocumentStart, forMainFrameOnly: false)

	// Add the script to the WKUserContentController 
        wkWebController.addUserScript(script)

	// Register for receiving callbacks from javascript for a notification name 
        wkWebController.add(self, name: ”notification_name”)
 }

3. Implement the delegate method

 func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if (message.name == “notification_name”) {
            print("Page changed")
        }
    }

As you probably saw, its important to use the same identifier in both JS and Swift code for a certain notification, in this current exampleiOSiOS 11WkwebviewWkcookiestorageWkprocesspool that identifier is “notification_name”.

That’s it, if you are interested in other cool features of the WKWebView I strongly encourage you to watch the following WWDC video.

Happy coding!📱