software engineering Apr 12, 2017

JavaScript Manipulation on iOS Using WebKit

As iOS developers, there are times we want to include web content inside our iOS apps. We may want to load content from a website that pairs with a native app version, or we may want to let the user open links without having to open another browser. Prior to iOS 8, we’d have to use a UIWebView which was clunky, leaked memory, and difficult to debug. After iOS 8, however, Apple deprecated UIWebView for WKWebView and introduced the modern WebKit API. The new framework dramatically improved both the performance and flexibility of adding web content into iOS apps, giving developers more control and more power. It also drastically improved communication with JavaScript natively.

In this post, I’ll show you examples of how to inject scripts into your webpages and receive data to do things like change the background of the webpage or call native functions directly from JavaScript.

WKWebView

First announced at WWDC 2014, WKWebView was a game-changer for rendering web content in iOS apps. It utilizes Core Animation and hardware acceleration so that webpages could scroll at 60fps. Apple developers ripped out the old JavaScript engine and replaced it with Nitro–the same engine that underlies Safari. It also includes the same built-in gestures for zooming and navigating backwards and forwards as the ones in Safari. It’s super easy to create too! Let’s start with a simple example of what a WKWebView looks like out of the box.

All you need to do to get started is create a WKWebView object, add constraints, and pass in a URLRequest to load a webpage:

override func viewDidLoad() {
        super.viewDidLoad()

        let webView = WKWebView(frame: .zero)

        view.addSubview(webView)

        let layoutGuide = view.safeAreaLayoutGuide

        webView.translatesAutoresizingMaskIntoConstraints = false
        webView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor).isActive = true
        webView.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor).isActive = true
        webView.topAnchor.constraint(equalTo: layoutGuide.topAnchor).isActive = true
        webView.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor).isActive = true

        if let url = URL(string: "https://www.google.com") {
            webView.load(URLRequest(url: url))
        }
    }

If you run this in the simulator, you should see something like this:

Note that you can only load URLs that are secure by default (i.e., only HTTPS connections). You can add the “App Transport Security Settings” key to your Info.plist to override this for development purposes. Then, under App Transport Security Settings, add the key “Allow Arbitrary Loads” and set its value to “YES”:

allow arbitrary loads

This will bypass the HTTPS requirement so you can test using localhost or using an HTTP connection. Keep in mind, however, that this is only meant to be used for development and not for production. You should always abide by Apple’s security standards for loading web content securely over industry-standard protocols.

WKUserContentController

So, we’ve loaded web content in our app using just a few lines of code. What if we want to, say, modify the webpage in our app? We can instantiate a WKWebView object like before, but this time pass in a new configuration object of type WKWebViewConfiguration:

let config = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: config)

Here, there’s a whole host of properties you can tinker with when the web view is initialized. For instance, you can control whether the page renders incrementally, which media types require touch gestures before playback, whether HTML5 videos can be displayed picture-in-picture, or how to communicate with loaded scripts. WKWebViewConfiguration has a property called userContentController that lets you pass in a WKUserContentControllerobject. This object injects JavaScript using addUserScript(_:) and listens to message handlers via add(_:name:). If you’re a web developer, this is similar to what browser plugins like Chrome extensions do with loaded web content.

User Scripts

The WKUserScript object, when added to the userContentController, allows developers to take JavaScript and inject it into a webpage. Here’s a simple example of adding a script to change the background color of the Google web page from above:

let contentController = WKUserContentController()
let scriptSource = "document.body.style.backgroundColor = `red`;"
let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
contentController.addUserScript(script)

let config = WKWebViewConfiguration()
config.userContentController = contentController

let webView = WKWebView(frame: .zero, configuration: config)

The init method takes in three parameters:

  1. source: Pass in a string representation of your JavaScript as your source.
  2. injectionTime: Specify whether the JavaScript loads at document start or at document end. If you pass in WKUserInjectionTime.atDocumentStart, your script will run right after the document element has been created but before any of the document has been parsed. If you pass in WKUserInjectionTime.atDocumentEnd, then your script will run after the document is finished parsing but before any subresources (e.g., images) have loaded. This corresponds with when the DOMContentLoaded event is fired.
  3. forMainFrameOnly: Specify whether your script runs in all frames or just in the main frame.

For your source, you can simply pass in a string or, if the script is more complex, load it from a local file in Xcode. To do this, add your JavaScript file to Xcode, get the path to the file, and initialize a string with the contents of the file:

uard let scriptPath = Bundle.main.path(forResource: "script", ofType: "js"),
      let scriptSource = try? String(contentsOfFile: scriptPath) else { return }

let userScript = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
userContentController.addUserScript(userScript

This is a brief overview of what it’s like to add a basic user script to a web view at init time. What else can you write a user script to do? Well, it can do anything a normal script on the webpage can do–modify document structure, listen to events like onload, load external resources (e.g., images, XMLHTTP requests). It can also communicate back to your app using script messages using the WKScriptMessageHandler protocol.

Script Messages

While user scripts may let you inject JavaScript code into your webpage, script messages let you call native code from JavaScript. To do this, there are a few steps on the iOS side:

  1. For each handler you want to add, call add(_:name:) on your WKUserContentController object. The name parameter will be important later.
  2. Have your view controller conform to the WKScriptMessageHandler protocol.
  3. Implement the required function userContentController(_ :didReceive:).

A message handler is a listener that will fire and return data once some JavaScript event completes. For example, you can have a handler to parse JSON data fetched from a URL. By including these message handlers into your WKUserContentController object, your web view will define a new function window.webkit.messageHandlers.name.postMessage(messageBody) that can be called in all frames. Here’s a simple example of adding a message handler called “test” that, when, called in your script tag, will print “Hello, world!”:

override func viewDidLoad() {
  super.viewDidLoad()
  
  let config = WKWebViewConfiguration()
  let userContentController = WKUserContentController()

  userContentController.add(self, name: "test")

  config.userContentController = userContentController
  
  ...
  
}

extension ViewController: WKScriptMessageHandler {
  func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
      if message.name == "test", let messageBody = message.body as? String {
          print(messageBody)
      }
  }
}

Then, your JavaScript should include the following call somewhere:

<script>
 
  function printHelloWorld() {
      window.webkit.messageHandlers.test.postMessage("Hello, world!");
  }
  window.onload = printHelloWorld;
 
</script>

For the message body, you can post any JSON object you’d like and your iOS app will capture it as a WKScriptMessage object that automatically converts the JSON object into native Swift types. For instance, if you pass JSON that looks like this…

{
	"name": "Lily",
	"breed": "Pug",
	"age": 1
}

…you can get the age by typecasting like this:

if let messageBody = message.body as? [String: Any], let age = messageBody["age"] as? Int {
	print("Age: \(age)")
}

Super cool!

Though this will work perfectly in most cases, Apple may convert your JSON objects into native types that you might not expect. Per Apple’s documentation, “Allowed types are NSNumberNSStringNSDateNSArrayNSDictionary, and NSNull.” This means that a Boolean type, for instance, will be converted into 0 for false, and 1 for true.

Advanced JavaScript Usage

Adding the window.webkit.messageHandler functions into your JavaScript code is pretty slick, right? Well we can do even better.

Let’s say that you wanted to create an iOS and Android app simultaneously where both apps loaded the same webpage and the same JavaScript code. JavaScript that includes these message handler functions wouldn’t compile if it’s loaded in anything but a WKWebView object (you can try loading it in a Chrome browser and you’ll get an error). That means you’d need specific separate JavaScript code for iOS and Android. Not very DRY, is it? Instead, what if we injected the message handler functions directly into the webpage on init? That way, we can reuse the same web pages without duplication and decouple the client from the server. Below is an example of injecting the same message handler from above, but this time injecting it via user scripts:

class ViewController: UIViewController {
...

  // Create your script. You can simply create a string inline, load it from a local file, 
  // or load from a URL. It’s up to you.
  let scriptSource = "window.webkit.messageHandlers.test.postMessage(`Hello, world!`);"
    
  // Instantiate a WKUserScript object and specify when you’d like to inject your script 
  // and whether it’s for all frames or the main frame only.
  let userScript = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
  userContentController.addUserScript(userScript)

...
}

If all goes well, you should see Xcode’s console print “Hello, world!” You can see a working demo of this here.

This only scratches the surface of what you can do with user scripts and message handlers. I’ve even used this approach to create my own native callbacks from JavaScript libraries by injecting message handlers into the callback functions and relaying the data to my app. The sky’s the limit!

Conclusion

WebKit offers a powerful suite of tools for iOS developers to manipulate JavaScript directly inside a webview in a native app without focusing on boilerplate code. You can inject JavaScript directly in the webpage using user scripts in just a single line of code . You can also send data to your app from JavaScript by conforming to the WKScriptMessageHandler protocol, adding the name of your message handler, and implementing userContentController(_ :didReceive:).

There’s so much you can do with JavaScript inside your iOS apps–even beyond loading web content. If you want to continue exploring, I suggest reading up on Apple’s JavaScriptCore framework for running JavaScript directly inside your app (FYI this is, in part, what powers frameworks such as React Native) and the recent addition of SFSafariViewController for mimicking the user experience of Safari even closer with AutoFill, Fraudulent Website Detection, and more.

I hope you learned a thing or two about WebKit–thanks for reading!

Ray Kim
Former Software Engineer Capital One
@rckim77

DISCLOSURE STATEMENT: These opinions are those of the author. Unless noted otherwise in this post, Capital One is not affiliated with, nor is it endorsed by, any of the companies mentioned. All trademarks and other intellectual property used or displayed are the ownership of their respective owners. This article is © 2018 Capital One.