June 13, 2014

Swift, WKWebView, and Calling from Swift to Javascript

Posted in Uncategorized tagged , , , , at 7:34 pm by tetontech

Update: If you are interested in this topic you may find this new post helpful. It describes and updated version of the example code you see here and standardizes it for both Swift and Android.

In a previous post, I showed how to embed Apple’s new WKWebView using Swift to create a ‘full view’, hybrid JavaScript application. An ability that QC Hybrid and PhoneGap, both early hybrid development platforms, had was to allow calls to be made from Javascript down to the native language of the device. In the case of iOS that was, and can still be, Objective-C but now can also be done in Swift. The new WKWebView has new functionality available in both languages that requires much less overhead to accomplish this interoperability than the old iOS UIWebView and Android WebView. The WKWebView currently does have an important limitation I’ll discuss in a moment.

There are two parts needed to achieve iOS’s new interoperability; a ‘listener’ of type WKScriptMessageHandler on the Swift side and a new object that has a postMessage() method on the Javascript side. When the Javascript postMessage() method is passed an array, associative array, object, or other Javascript type, the postMessage() method will JSON what it was passed. It then pushes this JSON string as a message to a queue on the Swift side of the application. At this point the JSON is parsed and the message becomes the matching Swift types, Array, Dictionary, String, Int, Double, etc. Custom Javascript objects become Dictionaries. Now your code in the WKScriptMessageHandler’s didRecieveMessage() method has Swift objects without needing to use JSON libraries in either the Javascript or Swift portions of your code. This is great. Unfortunately, at the time of this posting, there is no matching functionality in the beta release to easily push Swift objects back up to Javascript. Hopefully this will come soon. If not, there will need to be some ‘hack-like’ work-arounds similar to what QC Hybrid and PhoneGap had to do. This would be unfortunate.

The source code for this posting’s example is a modification of the previous post’s source. Rather than repeat the discussion of those portions of the example I’d suggest you review the previous post about how to do the embedding. The example below is the contents of a ViewController.swift file. It includes the changes needed to setup JS -> Swift interoperability (JS->Objective-C would use the same classes and methods) for an iOS single view application. The changes can be seen in lines 4, 14-18, and 29 – 31.

Line 4 declares the ViewController class as conforming to the WKScriptMessageHandler protocol. Notice that unlike Objective-C, Swift protocol declarations look just like object inheritance calls (in this case ViewController inherits from UIViewController). Swift only allows single inheritance, like Java. When doing both inheritance and protocol declarations the inheritance declaration must come first. If no inheritance is being done then a protocol declaration can be the first item after the : operator.

(Note: This code example has been updated for Swift 2.0 on my gitHub repository.)

1 import UIKit
2 import WebKit
3
4 class ViewController: UIViewController, WKScriptMessageHandler {
5    
6    override func viewDidLoad() {
7        super.viewDidLoad()
8        
9        //prepare
10        var path = NSBundle.mainBundle().pathForResource("index", 
                                        ofType: ".html")
11        var url = NSURL(fileURLWithPath:path)
12        var request = NSURLRequest(URL:url)
13        
14        //changes from last post
15        var theConfiguration = WKWebViewConfiguration()
16        theConfiguration.userContentController.addScriptMessageHandler(self, 
                                        name: "interOp")
17        var theWebView = WKWebView(frame:self.view.frame, 
                                        configuration: theConfiguration)
18        //end of changes
19        
20        theWebView.loadRequest(request)
21        self.view.addSubview(theWebView)
22    }
23
24    override func didReceiveMemoryWarning() {
25        super.didReceiveMemoryWarning()
26        // Dispose of any resources that can be recreated.
27    }
28    
29    func userContentController(userContentController: WKUserContentController!, didReceiveScriptMessage message: WKScriptMessage!){
30        println("got message: \(message.body)")
31    }
32
33 }

Line 15 introduces a new class, WKWebViewConfiguration. This class manages various types of configurations for any and all WKWebViews used in your applications. One of the WKWebViewConfiguration’s properties is a content controller named userContentController. This is the object we need to access in order to setup the ViewController class as the ‘listener’ for messages from Javascript.

Line 16 shows how to assign the ViewController to be the Javascript message handler for any messages generated as part of the named message queue ‘interOp’. You can select any name you want instead of interOp. It just sounded good to me.

Line 17 shows how to initialize the WKWebView with both a frame and the WKWebViewConfiguration instance. This tells the WKWebView to create an opportunity to use the Javascript postMessage() method.

We’re almost done. The only other change needed is to create the ‘listener’ method, userContentController.didRecieveScriptMessage(). One of the parameters to this method will be the WKScriptMessage generated as part of the interoperation between your JavaScript and Swift. The message has a property named body that is the Swift version of the JSON data passed from JavaScript. Line 30 shows this example printing the Swift objects that the example sends from the Javascript side.

I created a simple Javascript function in the project’s main.js file that you can see below. Notice the new window.webkit.messageHandlers object. It has an attribute ‘interOp’ that matches the name we used on line 16 of the Swift code. The JavaScript interOp object is the ViewController since it conforms to the WKScriptMessageHandler protocol and was assigned on line 16 with the interOp name.

function sendMessage(){
    var aMessage = {'command':'hello', data:[5,6,7,8,9]}
    window.webkit.messageHandlers.interOp.postMessage(aMessage)
}

That’s it. Now we can send any object, array, or primitive as a message to Swift. Hopefully Google will not re-invent the wheel when they add this same type of interoperability to Android. They should make their Javascript side look exactly this same way. There is some advantage to copying.

For an example of how to call from JavaScript to Swift and then back to JavaScript see this other post.

Advertisements

11 Comments »

  1. Dustin said,

    As always very userful stuff. I do have a follow up question: Using this setup how would you then return a value from native back to the calling javascript function? Any help with that would be very appreciated.

    • tetontech said,

      That’s the tricky part. With the current beta API there is a private function to do this but nothing good in the public API. I’m working on a ‘hack’ like I mentioned in the posting to accomplish this. I just haven’t found anything that works well yet.

      I’m hoping in the next release Apple might have completed the Swift->JS portion and make it available.

  2. Anders Carlsson said,

    Very cool article! FWIW, I landed http://trac.webkit.org/changeset/169765 this week.

    • tetontech said,

      Thanks.

      It is nice to know that they are working on the Objective-C/Swift to Javascript functionality.

  3. tetontech said,

    Apple has added the Swift/Objective-C to JavaScript functionality in the new betas. The function of WKWevView is

    evaluateJavaScript(javaScriptString: String?, completionHandler: ((AnyObject!, NSError!) -> Void)?)

    This function has two parameters, an optional String to execute as JavaScript and an optional function or closure to execute once the JavaScript has completed.

    • Dustin said,

      That’s good news. I would love to see another posting with this used as a complete solution that builds on this post. You’ve been an excellent resource for my swift learning so far. Thanks.

  4. Chris2014 said,

    Does someone get this working on iOS devices in iOS 8.1? It works fine in iOS simulators but my local html failed to load local javascript file in iOS device. Can someone confirm that? I am having a difficult time with iOS 8.1

    • tetontech said,

      It looks like Apple has injected a defect in the WKWebView class. I’m trying to find a work around for this issue.

    • tetontech said,

      The problem is a change in the way WKWebView works. Previously it could load files from any location. Now it can only load files from the temp directory. This may change in the future.

      I’ve put some code together to move web-type files (html, js, css, etc.) to the temp directory location. Pass an array of file extension strings to this code and it will return a String that is the location of your index.html file after it has been moved. You can then use the index.html file location to create a URL and URL request the WKWebView will load.

      Calling the function:

      let (htmlLocationOptional,errorDescriptionOptional) = move WebFiles([“js”,”css”,”html”,”png”,”jpg”,”gif”])

      The function:

      func moveWebFiles(movableFileTypes:[String])->(String?, String?){
      let fileManager = NSFileManager.defaultManager()

      var indexPath:String?
      let resourcesPath = NSBundle.mainBundle().resourcePath
      let tempPath = NSTemporaryDirectory()
      let webPath = tempPath
      var anError:NSError?
      let resourcesList = NSFileManager.defaultManager().contentsOfDirectoryAtPath(resourcesPath!, error: &anError) as [String]
      for resourceName in resourcesList{
      var isMovableType = false
      for fileType in movableFileTypes{
      if resourceName.lowercaseString.hasSuffix(fileType){
      isMovableType = true
      }
      }

      if isMovableType{
      let destinationPath = webPath.stringByAppendingPathComponent(resourceName)
      if fileManager.fileExistsAtPath(destinationPath){
      var removeError:NSError?
      fileManager.removeItemAtPath(destinationPath, error:&removeError)
      if removeError != nil{
      return (nil,removeError?.description)
      }
      }
      let resourcePath = resourcesPath!.stringByAppendingPathComponent(resourceName)
      println(resourcePath)
      var copyError:NSError?
      fileManager.copyItemAtPath(resourcePath, toPath: destinationPath, error: &copyError)
      println(copyError?.description)
      if resourceName.lowercaseString == “index.html”{
      indexPath = destinationPath
      }
      }
      }
      return (indexPath,nil)
      }

  5. Chris2014 said,

    when I change from portrait to landscape, it did not resize propertly. I add some code in checkOrientation but it did not work. Is there a way that we can make the subview to resize. It looks like that the main view is changing orientation but the subview does not recognize the change in orientation. Thank you in advance.

    //This lock of code is in viewDidLoad
    theWebView = WKWebView(frame: CGRect(x: 0.0, y: 0, width: 375, height: 667))
    theWebView!.loadHTMLString(content!, baseURL: url)
    //this is subview
    self.view.addSubview(theWebView!)

    NSNotificationCenter.defaultCenter().addObserver(self, selector: “checkOrientation”, name: UIDeviceOrientationDidChangeNotification, object: nil)
    }

    func checkOrientation()
    {
    if(UIDeviceOrientationIsLandscape(UIDevice.currentDevice().orientation))
    {
    println(“landscape”)
    theWebView = WKWebView(frame: CGRect(x: 0.0, y: 0.0, width: 667, height: 375))
    }

    if(UIDeviceOrientationIsPortrait(UIDevice.currentDevice().orientation))
    {
    println(“portraight”)
    theWebView = WKWebView(frame: CGRect(x: 0.0, y: 25, width: 375, height: 667))
    }

    }

    • tetontech said,

      I didn’t include how to handle this in the initial posting for simplicities sake, but here is some code that should do what you want. Your code is replacing the WKWebView every time rotation happens. You do not want to do that. There will be significant flicker as the page reloads, and the user will loose their location in your app.

      What you want to do is modify the frame of the existing WKWebView. I’m just going to past the code from a ViewController here. In this example I don’t want to change the frame when facedown, faceup, portrait upside down, or when switching directly between landscape orientations

      class ViewController: UIViewController, WKScriptMessageHandler {
      var appWebView:WKWebView?
      var previousOrientation: UIDeviceOrientation?

      override func viewDidLoad() {
      super.viewDidLoad()

      NSNotificationCenter.defaultCenter().addObserver(self, selector: “orientationChanged”, name: UIDeviceOrientationDidChangeNotification, object: nil)

      //add to or remove from the array the extenstions of the web files that are part of your app
      let (theWebView,errorOptional) = buildSwiftly(self, [“js”,”css”,”html”,”png”,”jpg”,”gif”])
      if let errorDescription = errorOptional?.description{
      println(errorDescription)
      }
      else{
      appWebView = theWebView
      }

      }
      //modify this function to do any JavaScript/Swift interop communication
      func userContentController(userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage){
      let sentData = message.body as NSDictionary
      let aCount:Int = Int(sentData[“count”] as NSNumber)

      appWebView!.evaluateJavaScript(“storeAndShow( \(aCount + 1) )”, completionHandler: nil)
      }

      func orientationChanged(){
      let orientation = UIDevice.currentDevice().orientation
      println(UIDevice.currentDevice().orientation)
      if previousOrientation != nil && UIDeviceOrientationIsLandscape(orientation) != UIDeviceOrientationIsLandscape(previousOrientation!)
      && orientation != UIDeviceOrientation.PortraitUpsideDown
      && orientation != UIDeviceOrientation.FaceDown
      && orientation != UIDeviceOrientation.FaceUp{
      let actualView = appWebView!
      let updatedFrame = CGRect(x: actualView.frame.origin.x, y: actualView.frame.origin.y, width: actualView.frame.size.height, height: actualView.frame.size.width)
      actualView.frame = updatedFrame
      }
      println(“\(previousOrientation?.rawValue) \(orientation.rawValue)”)
      previousOrientation = orientation
      }
      override func didReceiveMemoryWarning() {
      super.didReceiveMemoryWarning()
      // Dispose of any resources that can be recreated.
      }

      }


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: