Welcome to Innominds Blog
Enjoy our insights and engage with us!

WKWebview to Native Code Interaction

By Naresh Kumar Devalapally,

Often in times, during the development of an iOS or Mac based application, developers come across situations where the app needs to interact in some way with a webpage that is being loaded. This is not equivalent to loading a REST API but actual loading of a web page within the application. Though there have been other components within iOS (UIWebview) and Mac (WebView) separately in the past, this experience and usage are merged by the introduction of WKWebview. According to the official documentation, usage of WKWebview is recommended for both iOS and Mac apps.

The interaction between native code and WKWebview will help in various aspects while developing an app that loads a webpage. Following use cases can be addressed:

  • Filling out a troubleshoot form within the app
  • Any support request form within the app
  • A webpage that might need user credentials that are already stored in the app

There are other use cases where the app might want to inject some of the elements within the app. A bit of background in HTML/Javascript and native iOS development is needed to understand and execute this exercise.

Interaction between native code and WKWebview

Structure

This is a three part tutorial that takes four types of use cases while loading and interacting with the webpage.

  1. Simple interaction
  2. Injected interaction
  3. Advanced promise based contextual interaction

Basic Interaction

At the base level, the interaction between WKWebview's webpage and native code is done in two distinct ways.

Sending Data from WKWebview's Webpage to Native Code

To achieve this, the WKWebview needs to have a WKUserContentController configured that uses message handlers. Here is a sample configuration and definition.

lazy var webView: WKWebView = {

let webCfg:WKWebViewConfiguration = WKWebViewConfiguration()

// Setup WKUserContentController instance for injecting user script

var userController:WKUserContentController = WKUserContentController()

// Add a script message handler for receiving messages over `nativeProcess` messageHandler. The controller needs to confirm

// with WKScriptMessageHandler protocol

userController.add(self, name: "nativeProcess")

// Configure the WKWebViewConfiguration instance with the WKUserContentController

webCfg.userContentController = userController;

// Assign the size of the WebView. Change this according to your need

let webView = WKWebView(frame: CGRect(x: 0, y: 200, width: self.view.frame.width, height: self.view.frame.height-200.0), configuration: webCfg)

return webView

}()

 In the above example, nativeProcess defines the message thread over which the Javascript code can call a native swift method. Multiple userController objects can be defined for different names or the same controller can be defined for different process names (Eg. nativeProcess is the name of the process here). Here is a sample Javascript code that needs to be called.

var data = "data to send";
// nativeProcess here is same as the one defined in the swift code.
window.webkit.messageHandlers.nativeProcess.postMessage(data);

The above code is received by the controller, which confirms to WKScriptMessageHandler protocol.

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print("User message got")
      print(message.name) // prints nativeProcess string
        if(message.body is String){
             print(message.body) // prints the data that is sent from javascript
        }
    }

In the above example, a simple string is sent from Javascript to native swift code via WKScriptMessageHandler interface. The type of data that can be sent in message.body can be String or [String:Any] which means one level of key‑value dictionary. Multiple levels of dictionary are not supported.

Example

Valid data object

{
"token":838383838,
"id":"ddfis93-0302"
}

Invalid data object

{
"user":"ndevalap@ndsfie.com",
"profile":{"url":"https://imgur.com/93884"}
}

Sending data from native swift to web page

Unfortunately, there is only one way for the data to be sent from native to webpage. This is done by using evaluateJavascript function.

var jsToCode = "someJSFunction()"
webView.evaluateJavaScript(jsToCode) { (resp, err) in
	print("Finished evaluating javascript code")
}

Simple Interaction

This is the case where the webpage elements are defined along with the Javascript to change the elements. Consider an example where the methods to change the page and to interact with the native code are already written. In this case, the webpage of the server contains the code as follows:

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
			<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
				<meta http-equiv="X-UA-Compatible" content="ie=edge">
					<title>Simple HTML page</title>
					<script>
    
    function changeEcho(newText){
        var echoElement = document.getElementById("echo");
        echoElement.innerText = newText; 
    }

    function sendToNative(message){
        var native = window.webkit.messageHandlers.nativeProcess
        native.postMessage(message)
    }

    function sendMessage(){
        var element = document.getElementById("ping");
        console.log(element.value);
        sendToNative(element.value);
        element.value = "";
    }

    </script>
				</head>
				<body>
					<div class="container">
						<h2>Simple HTML Interaction </h2>
						<p>Hello this is a simple Javascript based page. Values entered in text box will be reflected</p>
						<p id="echo">Echoed here.</p>
						<hr/>
						<h3> Native</h3>
						<p>Value entered here will be sent back to phone</p>
						<input id="ping" placeholder="Enter to send" class="input"/>
						<button class="button" onclick = "sendMessage()" >Send</button>
					</div>
				</body>
			</html>

So, in the above webpage, sendToNative method called the swift code and sendMessage is a method defined in the webpage that populates the input element with id ping. In this case, all the code required for the interaction with the native code and all the modification code for changing the webpage are already present in the webpage source code. In most of the cases, this may not be possible as the mobile developer may not have access to the server web code and the webpage might be used for other platforms.

Wouldn't it be nice to have a way for the mobile developer to inject their own code into the html page that would be able to handle all the interactions? This is answered with injected interaction.

Injected Interaction

This type of interaction comes into picture when a developer does not have any access to the webpage that is being loaded or to be exact, the changes on the webpage are not advised on the server side. This comes in handy when the server team or whoever is maintaining the webpage in server denies changes to that page due to several business or technical reasons.

Consider the same case as the simple interaction where the Javascript is not included with the HTML page. In this case, we use the WKUserScript class to handle the injection. A WKWebView object is usually invoked with a WKConfiguration, which, in turn, may contain a controller object. Here is a sample code that depicts the usage.

lazy var webView: WKWebView = {
        let   webCfg:WKWebViewConfiguration = WKWebViewConfiguration()
        
        
        // Setup WKUserContentController instance for injecting user script
        var userController:WKUserContentController = WKUserContentController()
        
        var script:String?
        
        /// Get the contents of the file `inject.js`
        if let filePath:String = Bundle(for: ViewController.self).path(forResource: "inject", ofType:"js") {
            
            script = try! String(contentsOfFile: filePath, encoding: .utf8)
        }
        
        
         let userScript:WKUserScript =  WKUserScript(source: script!, injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: false)
        
        userController.addUserScript(userScript)
        
        // Configure the WKWebViewConfiguration instance with the WKUserContentController
        webCfg.userContentController = userController;
        
        let webView = WKWebView(frame: CGRect(x: 0, y: 200, width: self.view.frame.width, height: self.view.frame.height-200.0), configuration: webCfg)
        return webView
}()

It basically injects the contents of inject.js file at the start of any document that the webview loads. Now the Javascript inside the file can be maintained and manipulated as per the required specifications.

// Contents of inject.js file
/**
 Function that changes the text element content
 **/
function changeEcho(newText){
    var echoElement = document.getElementById("echo");
    echoElement.innerText = newText;
}

/**
 Send a message to native version of the application
 **/
function sendToNative(message){
    // Initiate the handle for Native process
    var native = window.webkit.messageHandlers.nativeProcess
    native.postMessage(message)
}

/**
 Click event to send the message to native
 **/
function sendMessage(){
    var element = document.getElementById("ping");
    console.log(element.value);
    sendToNative(element.value);
    element.value = ""; // reset value
}

The same logic appears if we load the page as described in the simple interaction section but without the Javascript on webpage. It solves the problem of having no control over the webpage.

The kind of solutions that you can build are endless where you can manipulate the DOM structure of a page, and auto-fill forms based on the information available within the application. This may also be used to auto submit some of the forms if not needed. There is a particular caveat at this type of implementation where the context will not be available in the native code. It is better understood if you are into Javascript development and have used promise based asynchronous interaction. Consider a case where the UI on iOS has to make a call to another web service to get the details of a particular function that is triggered by Javascript of a loaded webpage. Sounds too complex, but this can be easily explained with a use case:

  • Webpage loads a login screen
  • The user enters the credentials in the webpage
  • JS from webpage triggers the login function in native code
  • Native iOS code fetches the information and makes an API call to get token
  • Native iOS responds back to JS with the context of API call and the response

All these can be handled using the advanced mechanism.

Advanced and Promise Based Contextual Interaction

To be able to proceed further, a bit of Javascript background and a fair understanding of Promise in Javascript is necessary. You can also learn more about Promises here. Apart from the above examples, there are many resources that may help you in understanding the basics and implementation of a Promise. The implementation I am using is kind of a half‑baked type of Promise. In your additional projects, you may use the same approach within your projects.

Problem Description

The solution is best described by the example wherein the webpage demands a random profile from randomuser website and the native code supplies with a random profile.

  • iOS app opens a webpage
  • Webpage triggers a native code that fetches the data asynchronously
  • Native iOS app code fetches and sends the data back to webpage asynchronously
  • Webpage receives and displays the data in the body

Please look into the "Experimental " section of the application for these details. The solution is put up in the following steps:

  1. Load the webpage
    This is done by loading the experimental.html into the WKWebview
  2. Webpage requests the native for randomUser and a promise UUID
    // File experimental.js
    getRandomUser: function(jsCallback){
            
        var newPromiseId = generateUUID();
    
        // Specific for this implementation. You can modify this for your requirements later.
        var nativeData = {
            guid:newPromiseId
        };
        window.webkit.messageHandlers.nativeProcess.postMessage(nativeData);
        promises[newPromiseId] = jsCallback;
        }
    
    The above code generates a random UUID, and sends it across to native handler. This, in turn, calls the native function.
  3. Make the API call in native code and keep the UUID
    Once the native call is made, retreive and store the UUID for further calling. Make the API call using the following function:
    func fetchRandomUserProfile(guid: String) {
        //https://randomuser.me/api
        let request = NSMutableURLRequest(url: NSURL(string: "https://randomuser.me/api") !as URL,
            cachePolicy: .useProtocolCachePolicy,
            timeoutInterval: 10.0)
        request.httpMethod = "GET"
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: {
            (data, response, error) - > Void in
            if (error != nil) {
                print(error)
            } else {
                let httpResponse = response as ? HTTPURLResponse
                let theString = String(data: data!, encoding: .utf8)
                print(theString)
                DispatchQueue.main.async {
                    self.executeCallBack(guid: guid, data: theString!)
                }
    
            }
        })
    
        dataTask.resume()
    }
  4. Call the executePromise with the data received. This will have all the JSON data and the UUID to get the Promise reference from the initial Javascript and sends it back to Javascript.
    func executeCallBack(guid: String, data: String) {
        let execString = String(format: "executePromise('%@','%@')", guid, data)
        print(execString)
        webView.evaluateJavaScript(execString) {
            (data, err) in
            print("Finished calling")
        }
    }
  5. Fetch the Promise and execute it with the given data
    Once the executePromise method is called, you may execute the Promise by fetching from the dictionary of Promises.
    /// File: experimental.js
    function executePromise(guid,data){
        var theFunction = promises[guid];
        if(theFunction){
          // Got the function. Call it by native javascript call method.
            theFunction.call(null,data); // first parameter is context. Leave it as null if you want the context that called the function.
        }
        else{
           // Promise does not exist or function does not exist
        }
        delete promises[guid];
    }

Effective Files in Project

JS File

 experimental.js 

var randomApi = {
    getRandomUser: function(jsCallback) {

        var newPromiseId = generateUUID();

        // Specific for this implementation. You can modify this for your requirements later.
        var nativeData = {
            guid: newPromiseId
        };
        window.webkit.messageHandlers.nativeProcess.postMessage(nativeData);
        promises[newPromiseId] = jsCallback;
    },

    onRandomUser: function(data) {
        console.log("Got random user");
        // document.getElementById("rawData2").innerHTML = data;
        var userData = JSON.parse(data);
        if (userData) {
            var currentUser = userData.results[0];
            var fullName = currentUser.name.title + ' . ' + currentUser.name.first + ' ' + currentUser.name.last;
            document.getElementById("firstName").innerHTML = fullName;
            var email = currentUser.email;
            document.getElementById("email").innerHTML = email;
            var picUrl = currentUser.picture.large;
            document.getElementById("pic").src = picUrl;
            var dob = new Date(currentUser.dob.date);
            var dateOfBirth = dob.getDate() + ' / ' + (dob.getMonth() + 1) + ' / ' + dob.getFullYear();
            document.getElementById('dateOfBirth').innerHTML = dateOfBirth;
        }

    },

}

function fetchRandom() {
    randomApi.getRandomUser(randomApi.onRandomUser);
}

/**
 General handling for all the promises within the application
 **/

var promises = {};

// generates a unique id, not obligator a UUID
function generateUUID() {
    var d = new Date().getTime();
    var uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = (d + Math.random() * 16) % 16 | 0;
        d = Math.floor(d / 16);
        return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
    });
    return uuid;
};

function executePromise(guid, data) {
    var theFunction = promises[guid];
    if (theFunction) {

        theFunction.call(null, data);
    }

    delete promises[guid];
}

Swift File for Native Interaction

ExperimentalViewController

//
//  ExperimentalViewController.swift
//  WebInteractive
//
//  Created by Naresh Kumar Devalapally on 7/31/18.
//  Copyright © 2018 Naresh Kumar Devalapally. All rights reserved.
//
import UIKit
import WebKit

class ExperimentalViewController: UIViewController, WKScriptMessageHandler {

        lazy
        var webView: WKWebView = {
            let webCfg: WKWebViewConfiguration = WKWebViewConfiguration()


            // Setup WKUserContentController instance for injecting user script
            var userController: WKUserContentController = WKUserContentController()

            // Add a script message handler for receiving  "buttonClicked" event notifications posted from the JS document using window.webkit.messageHandlers.buttonClicked.postMessage script message
            userController.add(self, name: "nativeProcess")
            // Configure the WKWebViewConfiguration instance with the WKUserContentController
            webCfg.userContentController = userController;

            let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: self.view.frame.width, height: self.view.frame.height), configuration: webCfg)
            return webView
        }()

        override func viewDidLoad() {
            super.viewDidLoad()

            // Do any additional setup after loading the view.
            self.navigationItem.title = "Experimental HTML"
            self.view.addSubview(webView)
            let urlToLoad = URL(string: URLS.experimentalURL)
            // Do any additional setup after loading the view.
            webView.load(URLRequest(url: urlToLoad!))
        }

        override func didReceiveMemoryWarning() {
            super.didReceiveMemoryWarning()
            // Dispose of any resources that can be recreated.
        }


        /// Assuming that the javascript sends message back, this function handles the message
        ///
        /// - Parameters:
        ///   - userContentController: controller
        ///   - message: Message. Can be a String or [String:Any] to a single level.
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            print("User message got")
            if let theBody = message.body as ? [String: Any] {
                if let guid = theBody["guid"] as ? String {
                    print("guid of the promise is " + guid)
                    fetchRandomUserProfile(guid: guid)
                }
            }

        }


        // MARK: API from Random user implementation.

        func fetchRandomUserProfile(guid: String) {
            //https://randomuser.me/api
            let request = NSMutableURLRequest(url: NSURL(string: "https://randomuser.me/api") !as URL,
                cachePolicy: .useProtocolCachePolicy,
                timeoutInterval: 10.0)
            request.httpMethod = "GET"
            let session = URLSession.shared
            let dataTask = session.dataTask(with: request as URLRequest, completionHandler: {
                (data, response, error) - > Void in
                if (error != nil) {
                    print(error)
                } else {
                    let httpResponse = response as ? HTTPURLResponse
                    let theString = String(data: data!, encoding: .utf8)
                    print(theString)
                    DispatchQueue.main.async {
                        self.executeCallBack(guid: guid, data: theString!)
                    }

                }
            })

            dataTask.resume()
        }

        func executeCallBack(guid: String, data: String) {
            let execString = String(format: "executePromise('%@','%@')", guid, data)
            print(execString)
            webView.evaluateJavaScript(execString) {
                (data, err) in
                print("Finished calling")
            }
        }

These two files will help you understand the intricate logic and callback mechanism for web interaction.

Conclusion

Apple has put up certain limitations on the usage of web‑based interactions as compared to Android that helps in registering the objects. However, these can be overcome by the advanced usages as mentioned above. It also forms some of the basic building blocks for the reactive type of coding.

 

Topics: Mobility

Naresh Kumar Devalapally

Naresh Kumar Devalapally

Technical Manager - Software Engineering

Subscribe to Email Updates

Authors

Show More

Recent Posts