WKWebView and JavaScript interaction

As you probably know UIWebView has been deprecated in iOS 13, I’ve use it in my projects for years and it was really convenient to display HTML pages inside an app but even to interact with Javascript, for example to perform operations on JSON files sharing the same login with Android and even with the Web. Having an hidden UIWebView was an easy and convenient way to execute JavaScript, if that’s the only thing you need to do you can use JavascriptCore, I’d probably write something about it in the future but for know let’s focus on WKWebView and how to call a JS function from it or receive messages from a page you’re displaying in your app.
As usual there is a GitHub project with all the examples if you want to have a look at the sources and use some of the classes in your projects.

Creating a WKWebView

My sample project uses a class called WebViewHandler (see the implementation here) that creates a WKWebView and act as its WKNavigationDelegate and conforms to the WKScriptMessageHandler protocol as well, so everything is contained into the class that only exposes its WKWebView so the owner can add it to the view hierarchy and manage its appearance, but not have to deal with other details.


let preferences = WKPreferences()
preferences.javaScriptEnabled = true
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
        
webView = WKWebView(frame: CGRect.zero, configuration: configuration)
        
super.init()
configuration.userContentController.add(self, name: messageName)
webView.navigationDelegate = self

WKPreferences aren’t really necessary, but I left them here to tell you that you can disable JavaScript in the pages you load by setting javaScriptEnabled to false.
Instead the WKWebViewConfiguration object is necessary if we want to enable the message passing between the page and our code. By adding self as userContentController we can receive messages from the page, I’ll show you how later on.
The last line sets the class as the WKNavigationDelegate of the web view and that’s important for two reasons: we want to know when the page has finished loading and we want to check the URLs visited by the page. This is an alternative way to communicate from the page to the code, and it was the only one possible with UIWebViews.

Calling a JavaScript function

The method to call a function is similar to the one available in UIWebView, the only difference is that with the old web view we had a synchronous call with a String returned, while with WkWebView we have a callback with an Error an an optional Any object returning from the Javascript function. The sample JavaScript for this project can be found here


private func callJavascriptFunction(function:JavascriptFunction) {
    webView.evaluateJavaScript(function.functionString) { (response, error) in
        if let _ = error {
            function.callback(false, nil)
        }
        else {
            function.callback(true, response)
        }
    }
}

This is how we can call a JavaScript function in Swift. If there is an error we get a description into the error object, otherwise response contains what is returned by the function. It could be a String, but it could even be an object, like a dictionary or an array. That’s a step forward, as UIWebView only returned a string so you had to do JSON.stringify in order to pass an object back to the native code.

Passing parameters via URL

As I said there are two ways to communicate from the page to the native code running on the app. The first one is compatible with UIWebView and involves redirecting the page to an URL with a particular scheme, passing parameters into the URL itself like you can do in a GET request to a web server.


var urlPrefix = "nativeapp://";

function sendParameters() {
    window.location = urlPrefix + "parameters?parameter1=100¶meter2=200¶meter3=abcd";
}

You could, in theory, call something like http://127.0.0.1/ and intercept that, but it I own the page I display on a WebView I prefer using a custom prefix, so I’m sure those commands are meant to be read by my native code.
Let’s see how we can read that URL in code


func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    let url = navigationAction.request.url
    if let urlString = url?.absoluteString,
        urlString.starts(with: messageName),
        let parameters = ParametersHandler.decodeParameters(inString: url!.absoluteString) {
        delegate?.didReceiveParameters(parameters: parameters)
    }
    decisionHandler(.allow)
}

This is a method of WKNavigationDelegate that allow us to parse a particular URL and to block a request from being served. We could for example block every external URL from a page we are loading into our app, or block requests external to a particular domain.
For now, we only want to check if there are parameters inside the URL, but only if the url starts with our prefix nativeapp, and for that I created a class ParametersHandler you can find here to contain some convenience methods to parse URLs


class func decodeParameters(inString parametersString:String) -> [String:Any]? {
    if let convertedString = parametersString.removingPercentEncoding,
       let queryItems = URLComponents(string:convertedString)?.queryItems {
        var parameters:[String:Any] = [:]
        for item in queryItems {
            parameters[item.name] = item.value ?? ""
        }
        return parameters
    }
    return nil
}

First I convert the string removing the encoding, the ones JavaScript adds to a string when you call encodeURIComponent on it. This is necessary to transfer special characters, so you don’t need that if you only deal with ASCII, but if you are using UTF-8 you need to encode the string and to decode it in Swift, otherwise you’ll see a lot of characters starting with % and you won’t be able to extract informations from the URL.
Next we use URLCompponents.queryItems to get a dictionary of String:Any from the string. It is an optional, as you can see I’m using an if let statement and if I’m not able to get something from the call to queryItems I simply return null. In theory you could pass a JSON by putting its string representation as a parameter, then extract the parameters a a String and try to parse it, but there is a more convenient way to deal with dictionaries I’ll show you in the following paragraph.

postMessage

This is the second method not available with the old UIWebView. It allows JavaScript to send a message to the native code and it is possible to support multiple messages with different handlers if you need different kind of messages to be dispatched to different classes on your code.
We saw previously how to register an object to be the handler for a particular message by calling configuration.userContentController.add(self, name: messageName) during the WKWebView configuration.
How do we send the message from the web page?


function sendMessage() {
    window.webkit.messageHandlers.nativeapp.postMessage({paramter1 : "value1", parameter2 : "value2"})
}

nativeapp is the name of our message added to userContentController, and you can have more of those names if you like. In postMessage you can send an object like a dictionary or an array or you can send a string in a similar way you launched an URL with the custom scheme in order for it to get parsed.
Now let’s take a look at the Swift part


func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
    if message.name == messageName {
        if let body = message.body as? [String:AnyObject] {
            delegate?.didReceiveMessage(message: body)
        }
        else if let body = message.body as? String {
            if let parameters = ParametersHandler.decodeParameters(inString: body) {
                delegate?.didReceiveParameters(parameters: parameters)
            }
        }
    }
}

As you see I check the message name, that can be necessary if you register the same object for different messages, so you can handle all the messages in the same place but have different actions for different messages. As you can see I try to convert the message body to a dictionary of String:AnyObject, but failing at that I check if the message is a String and try to get parameters from it just like I did with the URL.

Base64

If you need to deal with UTF-8 you should be fine by calling encodeURIComponent as I mentioned before, but I want to show you how to decode Base64 encoded string as sometimes you have to deal with them. In JavaScript you can use btoa to encode and atob to decode a base64 string, let’s see how to decode it in Swift


class func decodeParametersBase64(inString parametersString:String) -> [String:Any]? {
    if let decodedData = Data(base64Encoded: parametersString),
        let decodedString = String(data: decodedData, encoding: .utf8) {
        return ParametersHandler.decodeParameters(inString: decodedString)
    }
    return nil
}

This method tries to get parameters from the string once decoded, the important part is the first if where we init a Data object with a base64 encoded string and try to decode it to an UTF-8 string.

JSON to string

For a quick reference I’ll show you how to encode and decode JSON strings, something you may need to do if you need to call a javascript function with a JSON as a parameter.


class func getJSON(fromString jsonString:String) -> Any? {
    if let data = jsonString.removingPercentEncoding?.data(using: .utf8),
       let jsonObject = try? JSONSerialization.jsonObject(with:data , options: .allowFragments) {
            return jsonObject
    }
    return nil
}

That’s how you can get a JSON object from a String, as you see I removed the encoding so if the JSON is inside a URL I can still read it.


class func getString(fromObject jsonObject:Any) -> String? {
    if let data = try? JSONSerialization.data(withJSONObject: jsonObject, options: .fragmentsAllowed),
       let string = String(data: data, encoding: .utf8) {
        return string
    }
    return nil
}

And finally this the method to convert an object to a string, like JSON.stringify in JavaScript.
If you want to see an example of passing a JSON to a function check out didReceiveMessage in ViewController where I put a simple object as a parameter to a function and print it in console in my JavaScript.

If you look at my WebViewHandler class you’ll see how I deal with the fact that JavaScript functions can be called only after the page is loaded, by queueing the function calls in an array while the page is loading and execute all the functions after the page is ready.
Happy coding 🙂