Communication: iOS to Javascript and Back

Posted by Grego on December 14, 2016

Over the past few days I’ve been experimenting with Javascript and iOS cross communication using a WKWebView. I was searching for a way reuse some existing JavaScript code from the native side on iOS.

I would generally recommend writing the iOS code in pure native Swift or Objective-C, however this was a requirement for a job.

Communicate with the WebView without Cordova. Most of the app will be iOS native, but just “borrow” some existing logic that is too complex to spend time rewriting and execute it from a hidden iOS webview in the app.

The resulting code from my testing can be found on GitHub. For anyone who is curious to see the full project. I may convert it to a pod later if it proves useful.

Part of the task involved taking the existing apple APIs and wrapping them so that the implementation could be swapped out at a later date in case things change.

How Does it Work?

In my Swift project there are two main classes that abstract the heavy lifting:

  • NativeMethodManager - takes care of handling calls from Javascript
  • Javascript - handles sending messages to Javascript.

NativeMethodManager - Adds Methods to Javascript

NativeMethodManager abstracts registering new methods, more properly known as message handlers, with the WebView so that the JS can call them and also acts as a delegate to handle the callback response from JS.

Let me demonstrate with pure swift vs. my NativeMethodManager class.

Pure Swift:

To create and expose a new method to JavaScript in pure swift:

class ViewController: UIViewController {
  /// holds a reference to the WKWebView on this ViewController.
  var webView: WKWebView?

  override func viewDidLoad() {
    let config = WKWebViewConfiguration()
    //adds a new messageHandler to the javascript called `newJsMethod`
    //and sets the delegate for the callback to this ViewController.
    config.userContentController.add(self, name: "newJsMethod")
    self.webView = WKWebView(frame: webFrame, configuration: config)
  }
}

/// Adds support for the WKScriptMessageHandler to ViewController.
extension ViewController: WKScriptMessageHandler {
  func userContentController(_ userContentController: WKUserContentController,
                             didReceive message: WKScriptMessage) {
    print("Received message from native: \(message)")
  }
}

This gives us access in the JavaScript to a WebKit message handler called newJsMethod, which can be invoked from the JavaScript as:

window.webkit.messageHandlers.newJsMethod.postMessage(message)

Where message can be of any JavaScript data type.

Using My NativeMethodManager:

Using the NativeMethodManager wrapper I created will hide some of the details and get us set up quicker, but does the exact same as the example above under the hood, with some extra functionality.

class ViewController: UIViewController {
  /// holds a reference to the WKWebView on this ViewController.
  var webView: WKWebView?

  override func viewDidLoad() {
    let config = WKWebViewConfiguration()
    //set up a new NativeMethodManager
    let methodManager = NativeMethodManager(configuration: config)

    //adds a new messageHandler to the javascript called `newJsMethod`
    //and sets a callback to be fired when this specific method is invoked from
    //the js side.
    methodManager.addMethod(name: "sayQuack", method: { (message) in
      if let name = message as? String {
        print ("Quack, \(name)")
      }
      print("Received `sayQuack` with message: '\(message)'")
    })

    self.webView = WKWebView(frame: webFrame, configuration: config)
  }
}

As the name suggests the NativeMethodManager also manages these methods as well. In this example, when we get a message from the JavaScript for a message handler called sayQuack the closure provided gets executed.

In the pure Swift example you’d have to implement checks yourself to see if message.name matches the method you are expecting.

Javascript - Executes Arbitrary JS in WebView

The purpose of the Javascript class is to send messages the other way and handle the response from the web view. The response could be anything from a function’s return value, an exception or any other kind of error in sending the JS to the web view.

Executing JS in Pure Swift:

class ViewController: UIViewController {
  func someFunction() {
    self.webView.evaluateJavaScript(code, completionHandler: { (result: Any?, error: Error?) in
      if error == nil {
        print("Js execution successful")
      }
      else {
        print("Received an error from JS: \(error)")
      }
    })
  }
}

Using My Javascript class:

class ViewController: UIViewController {
  /// allows us to execute arbitrary JavaScript code inside the WebView.
  var javascript: Javascript?

  /// holds a reference to the WKWebView on this ViewController.
  var webView: WKWebView?

  override func viewDidLoad() {
    // ... set up code from previous examples removed for brevity ...
    self.javascript = Javascript(webView: self.webView!)
  }

  func someOtherFunction() {
    self.javascript!.exec("someJavascriptFunction('Hey there squeaky'))",
                          completion: { (result: JavascriptResult) in
      print("JS code executed. Result: \(result)")
    }
  }
}

As I’m writing this article it’s actually making me wonder if I shouldn’t have abstracted the Javascript.exec(_, completion:) function more to handle calling a function and passing parameters for you instead of requiring you type type in and escape your own javascript as a string.

I also encapsulated the result and error in a single object. I’m thinking it may make more sense to have different closures to pass for error and success.

I’ll also be doing an implementation of this using PromiseKit, at which point I’ll make use of the promise’s resolve and reject functions to handle that in a cleaner fashion.

Results

What I’ve found is that it’s possible to send information back and forth between the WebView. Swift receives the result as an Any? object from JavaScript. The Swift native can then cast the object to a Dictionary or something more usable from native, from WKScriptMessage and WKScriptMessage.body:

Allowed types are NSNumber, NSString, NSDate, NSArray, NSDictionary, and NSNull.

See also: WKScriptMessageHandler

Snags I Ran Into

Along the way I hit a few bumps. The only one worth mentioning was trying to put the WKWebView on the ViewController for my example app. Now in production it will most likely be hidden, but for the demo I needed to be able to interact with both native and web directly for testing.

Adding a WKWebView to your ViewController:

Unfortunately the only way I could figure out that makes sense to add the WKWebView to the view controller was to make a containing controller and add it as a subview:

  1. Add a UIView to your view controller on Storyboard (if you use it). This view will contain our webview.

  2. Create the outlet for the view:

    @IBOutlet weak var container: UIView!
    

    Then connect the outlet.

  3. To set up the web view’s frame, we can steal some information from the container itself (height, width):

    var webFrame = self.container!.frame
    //set origin to 0, 0 or we will have some extra padding inside our container.
    webFrame.origin.x = 0
    webFrame.origin.y = 0
    
  4. Create the webview, and add it as a sub view:

    //configuration is used elsehwere for adding message handlers to the webview
    let config = WKWebViewConfiguration()
    
    //... in my demo, the code that adds message handlers would come here ...
    
    //instantiate webview
    self.webView = WKWebView(frame: webFrame, configuration: config)
    //add it as a subview to container:
    self.container!.addSubview(webView!)