July 17, 2014

Objective-C WKWebView to JavaScript and Back

Posted in iPhone development, mac development tagged , , , , at 4:50 pm by tetontech

 In a previous post I showed how to communicate from JavaScript to Swift and back.  This code example shows how to use the new WKWebView rather than the old UIWebView with Objective-C.

Since WKWebView doesn’t yet show up as a drag-and-droppable view in Interface Builder, and other IB work-arounds cause the app to crash, a WKWebView instance is created in the ViewController’s viewDidLoad method. The WKWebView instance is then sized to fill the entire view of the device. This can cause usability problems since the WKWebView’s content now overlaps the header bar. If I was creating an app to ship, I would use Interface Builder to add a UIView, create an IBOutlet to that view, and size the UIView to fit the display portion of the screen. I would then add the WKWebView to the UIView I added using Interface Builder.

The header file for the ViewController class shows how to include the WebKit headers and make the ViewController a WKScriptMessageHandler. The ViewController needs to be a WKScriptMessageHandler since, in this example, the ViewController is going to be sent messages from the JavaScript code.

#import <UIKit/UIKit.h>
#import <WebKit/WebKit.h>
@interface ViewController : UIViewController <WKScriptMessageHandler>
   
@end

In order to create the WKWebView and add it to the main view in viewDidLoad and later use the same WKWebView in userContentController:didReceiveScriptMessage method a WKWebView property called theWebView is added to the ViewController class.

The ViewController’s viewDidLoad method includes the creation of an instance of WKWebViewConfiguration. Among other things, WKWebViewConfiguration is used to setup and name the JavaScript message listener. This setup is done with the WKWebViewConfiguration userContentController’s addScriptMessageHandler:name method. For this example I’ve chosen ‘interOp’ as the name to be exposed to JavaScript for the message handler and used the ViewController as the message handler.

Once WKWebKit is debugged, the following code should work. It currently doesn’t (iOS 8.1) so I’ll show you a work-around right after this “is supposed to work but doesn’t code example.”

#import "ViewController.h"

@interface ViewController ()
    @property(strong,nonatomic) WKWebView *theWebView;
@end

@implementation ViewController
            
- (void)viewDidLoad {
    [super viewDidLoad];
    NSString *path = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
    NSURL *url = [NSURL fileURLWithPath:path];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    
    WKWebViewConfiguration *theConfiguration = 
          [[WKWebViewConfiguration alloc] init];
    [theConfiguration.userContentController 
          addScriptMessageHandler:self name:@"interOp"];
    
    _theWebView = [[WKWebView alloc] initWithFrame:self.view.frame 
                      configuration:theConfiguration];
    [_theWebView loadRequest:request];
    [self.view addSubview:_theWebView];
    
}

Since ViewController is a WKScriptMessageHandler, as declared in the ViewController interface, it must implement the userContentController:didReceiveScriptMessage method. This is the method that is triggered each time 'interOp' is sent a message from the JavaScript code.
- (void)userContentController:(WKUserContentController *)userContentController 
                            didReceiveScriptMessage:(WKScriptMessage *)message{
    NSDictionary *sentData = (NSDictionary*)message.body;
    long aCount = [sentData[@"count"] integerValue];
    aCount++;
    [_theWebView evaluateJavaScript:[NSString 
            stringWithFormat:@"storeAndShow(%ld)", aCount] completionHandler:nil];
}

In the example code above you can see a WKScriptMessage is received from JavaScript. Since WKWebKit defines JSON as the data transport protocol, the JavaScript associative array sent as the message's body has already been converted into an NSDictionary before we have access to the message. We can then use this NSDictionary to retrieve an int that is the value associated with the 'count' label. The JSON conversation creates NSNumbers for numeric type values so the code example retrieves the NSNumber's intValue, modifies it, and then sends the modified value back to JavaScript. 

One of the very nice features of WKWebKit framework is that the WKWebView runs independently of the main, or User Interface, thread. This keeps our apps responsive to user input. The storeAndShow method will not execute in the app's main thread. 

- (void)didReceiveMemoryWarning {
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

@end

The JavaScript examples below show how to send a message to Objective-C using webkit’s list of messageHandlers and the interOp handler we setup in the ViewController’s viewDidLoad method. An example of this is found in the sendCount JavaScript function. The storeAndShow function that is triggered is strait forward. All that function does is some standard JavaScript using the DOM (Document Object Model).

var count = 0

function sendCount(){
    var message = {"count":count}
    window.webkit.messageHandlers.interOp.postMessage(message)
}

function storeAndShow(updatedCount){
    count = updatedCount
    document.querySelector("#resultDisplay").innerHTML = count
}

Overall I am really pleased with the new WKWebView and the other classes in the WKWebView framework when it is compared to the old UIWebView. I like the WKWebView executing outside the main thread. I like the choice of JSON for the data transfer protocol. The adding of the message handlers is a little awkward, but nothing is ever perfect.

When you try to run this “should work but doesn’t code” the screen on your device will be blank. There is a scrollbar that you can move but the page is never displayed. This code does work on a device when written in Swift if you move all of the web components to the app’s temp directory before you build the NSURL. For the work-around we’ll do the same thing in Objective-C. I’ve written a small Objective-C class called WebMover to do that for you. It will move web files and any of you app’s web directories for you. When it does the move it will return the path to the index.html file to you so you can use it to create a NSURL. I won’t cover that code in this posting. You can get WebMover’s source and an example project from my gitHub ObjectivelyHybrid repo.

I have tracked down one of the issues that causes Objective-C to fail to load the page, even when the web files have been copied to the app’s temp directory. The problem is with this line of code.

NSURL *url = [NSURL fileURLWithPath:path];

If this line of code is executed, a NSURL is generated and appears to be good in all observable respects. The NSURL is defective. If the same line of code is executed in Swift, the defect disappears. Because of this, the work-around will generate the NSURL in Swift, but the rest of the code will remain Objective-C.

The working viewDidLoad method now looks like this.

@implementation ViewController
            
- (void)viewDidLoad {
    [super viewDidLoad];
    NSError *moveError = nil;
    //modify the array of file types to fit the web file types your app uses.
    NSString *indexHTMLPath = [WebMover moveDirectoriesAndWebFilesOfType:
                               @[@"js",@"css",@"html",@"png",@"jpg",@"gif"] error:&moveError];
    if (moveError != nil) {
        NSLog(@"%@",moveError.description);
    }
    //this is an Objective-C call to some Swift code.
    NSURL *url = [SwiftlyBridge buildURL:indexHTMLPath];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    
    WKWebViewConfiguration *theConfiguration = 
          [[WKWebViewConfiguration alloc] init];
    [theConfiguration.userContentController 
          addScriptMessageHandler:self name:@"interOp"];
    
    _theWebView = [[WKWebView alloc] initWithFrame:self.view.frame 
                      configuration:theConfiguration];
    [_theWebView loadRequest:request];
    [self.view addSubview:_theWebView];
    
}

The thing I like about this work-around is how close it is to the original “should work” solution. Unlike others who have suggested highly complicated things like building a web server into your app.

The SwiftlyBridge Code is also tiny. In the example project you can find it in the SwiftlyBridge.swift file.

import Foundation
import WebKit

@objc class SwiftlyBridge {
    
    class func buildURL(indexHTMLPath:String) ->NSURL{
        return NSURL(fileURLWithPath: indexHTMLPath)!
    }
}

When you add this file to your project, Xcode will ask you if you want it to create a bridging header file. The answer is an emphatic YES. Make sure you have it do this for you.

Once you’ve added the WebMover.m and .h files and the SwiftlyBridge.swift file maker SURE that you attempt to build your project. It will fail, but the build process will generate a .h, header, file for you that will allow you to import the SwiftlyBridge class. In the example project this generated header file is imported in to the view controller source, you can compile and run.

The name of the generated header is always <ProjectName>-Swift.h. The Example project’s name is ObjectivelyHybridiOSExample so the import call is

#import <ObjectivelyHybridiOSExample-Swift.h>

I’ve had trouble with projects that have spaces or dashes in there names. In those cases Xcode doesn’t seem to be able to find the generated header file.

 

That should do it. Post if you are having issues.

Advertisements

5 Comments »

  1. Fabio said,

    The code doesn’t work anymore on iOS8GM: looks like WKWebView could not access to local files.
    Have you find a way to made it works?

    • tetontech said,

      I just ran the code on Xcode 6 iOS GM and it worked for me. Select the project in the Xcode window, select Build Phases, Expand Copy Bundle Resources and make sure that the html, css, and javascript files are listed there. They should be if you added them to the project by dragging and dropping them or by adding and editing. If they are not found in the list of resources to be copied into the bundle add them by selecting the + button.

  2. Paul said,

    Hi tetontech,
    Just wondering if you have found a way of getting the JavaScript to receive a return value from the native code, synchronously.
    userContentController:didReceiveScriptMessage doesn’t have a return value, so the only way of passing a value back to the JS is through the messy evaluateJavaScript, which is no better what UIWebView’s stringByEvaluatingJavaScriptFromString, in this case.
    Although JavaScript code in an Android web view has had a synchronous native interface for ages, iOS seem to just not have it.

    • tetontech said,

      Paul,

      The evaluateJavaScript method has, as its last parameter, a closure in Swift or a code block in Objective-C. This closure/code block is called after the JavaScript has executed. Any value returned from the JavaScript is passed as the closure/code block’s first parameter. The second parameter of the closure/code block is an error. It will have a value if there was some error calling the JavaScript. I haven’t played with the error to see how it can be manipulated from the JavaScript side of an application.

      I’ve updated the Swift example on gitHub to show this working. When I get a few minutes I’ll also update the Objective-C example.

  3. […] Objective-C WKWebView to JavaScript and Back […]


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: