Fat GrinCatchlines, Articles, About

Working with HTML on iOS, an introduction

24 January 2014

If you want to display HTML content from within a native iOS App, UIKit provides you with a class for just that purpose, UIWebView. In this post we will look at setting up and loading a web view, getting messages when interesting things happen, and then communicating between the App and the HTML content using JavaScript.

To get the most out of this post download the sample code and follow along with the instructions.

Creating and loading a UIWebView

Edit the main view controller add a property to hold a reference to the web view.

@interface MyViewController : UIViewController
@property UIWebView *webView;
@end

In your view controllers viewDidLoad method instantiate an instance and add it to the view heirarchy.

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.webView = [[UIWebView alloc] initWithFrame:self.view.frame];
    self.webView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    [self.view addSubview:self.webView];
}

If you run your project now, you'll have an empty web view displayed as we haven't asked it to load any content. UIWebView has three different methods to supply its content, which you use depends your project.

- (void)loadRequest:(NSURLRequest *)request;
- (void)loadHTMLString:(NSString *)string baseURL:(NSURL *)baseURL;
- (void)loadData:(NSData *)data MIMEType:(NSString *)MIMEType textEncodingName:(NSString *)textEncodingName baseURL:(NSURL *)baseURL; 

loadHTMLString:baseURL: and loadData:MIMEType:textEncodingName:baseURL: can be used if you have already loaded your content and have a string or data representation.

If you haven't loaded your content loadRequest: will take care of that for you. An NSURLRequest can load content from the network or disk (with a file:// URL) or any other URL protocol the system is aware of.

Its worth stating here that UIWebView is not limited to loading HTML content, you can also load PDFs, Office Documents, RTF Documents, various image formats, etc. so are a very flexible aproach to getting your content displayed on screen.

Lets add a method to load some content in our web view. We're going to get the URL for a file in our sample project. To do that we'll ask our main bundle (the application bundle) for the URL of the content.bundle (the folder on disk that contains our HTML). Next we'll use get a relative URL to the index.html file within that bundle, create a NSURLRequest with it and ask the web view to load it. Finally we'll add a call to that method at the bottom of our viewDidLoad method.

- (void)viewDidLoad
{
    ...
    [self loadWebView];
}

- (void)loadWebView
{
    NSURL *htmlBundleUrl = [[NSBundle mainBundle] URLForResource:@"content" withExtension:@"bundle"];
    NSURL *url = [NSURL URLWithString:@"index.html" relativeToURL:htmlBundleUrl];
    NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
    [self.webView loadRequest:request];
}

In overview, we're getting a file system URL for an HTML file in our application and asking the web view to load it.

Adding a delegate

UIWebView has a delegate protocol UIWebViewDelegate to allow you to receive information about what a web view is doing, and instruct it which URL requests it should try to load.

UIWebViewDelegate defines the following optional methods.

- (void)webViewDidStartLoad:(UIWebView *)webView;
- (void)webViewDidFinishLoad:(UIWebView *)webView;
- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error;
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;

webViewDidStartLoad: and webViewDidFinishLoad: allow your delegate to perform actions when you start and finsih loading content in your web view. For example you may want to hide your web view while its loading and animate showing it when the content has finished loading. webView:didFailLoadWithError: is provided to allow you to recover from a failure to load.

The final, more interesting method, webView:shouldStartLoadWithRequest:navigationType: allows your delegate make decisions about which requests it should load.

Lets add our view controller as the web views delegate and stub out these methods to log to the console so we can see the order these delegate methods are called when loading our content from disk.

First we need to let the compiler know our view controller conforms to the UIWebViewDelegate protocol in our header file.

@interface MyViewController : UIViewController <UIWebViewDelegate>

Now we'll add all the delegate methods, but just have them log to the console.

- (void)webViewDidStartLoad:(UIWebView *)webView
{
    NSLog(@"Started loading");
}

- (void)webViewDidFinishLoad:(UIWebView *)webView
{
    NSLog(@"Finished loading");
}

- (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error
{
    NSLog(@"Failed to load with error: %@", error.localizedDescription);
}

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    NSURL *url = [request URL];
    NSLog(@"Should load url: %@", url);
    return YES;
}

Make sure webView:shouldStartLoadWithRequest:navigationType: returns YES or none of your content will load.

Finally we just need to tell our web view that we want to be its delegate, we'll do this in the view controlers viewDidLoad method.

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.webView = [[UIWebView alloc] initWithFrame:self.view.frame];
    self.webView.delegate  = self; // Add this line
    [self.view addSubview:self.webView];
    [self loadWebView];
}

Build and run your project and you should see the messages output to the console.

Communicating with your web view content

Now you've got a web view loaded with some of your content we can start to communicate with it. To communicate with the HTML from the App you can call down directly with JavaScript. UIWebView has the method stringByEvaluatingJavaScriptFromString: that can execute a JavaScript string in the web views context.

Add the following helper method to your MyViewController. The method:

  • Accepts an NSDictionary.
  • Encodes it as JSON string (using a category included in the sample project).
  • Creates a JavaScript string to call appBridge.messageFromApp.
  • Executes the JavaScript in the context of the web view.

If the JSON encode fails the the method will fail silently.

- (void)sendMessageToSite:(NSDictionary *)dict
{
    NSString *json = [NSString my_JSONStringWithObject:dict];
    if (json) {
        // We encoded some json data
        NSLog(@"SENDING MESSAGE TO SITE: %@", json);
        NSString *javascript = [NSString stringWithFormat:@"appBridge.messageFromApp(%@);", json];
        [self.webView stringByEvaluatingJavaScriptFromString:javascript];
    }
}

Next lets add a method to handle messages the App receives from the web view, we'll have the method follow a similar format and accept an NSDictionary as its argument.

- (void)receiveMessageFromSite:(NSDictionary *)dict
{
    NSLog(@"RECEIVED MESSAGE FROM SITE: %@", dict);
}

For now we'll just log the message to the console.

The content in the web view doesn't have any way to directly call our Objective C code so we can't use the same approach to send messages back up. What we can do is use webView:shouldStartLoadWithRequest:navigationType: to hijack certain requests and iterpret them as messages from the web views content.

If you look at the JavaScript code in the main.js file in the content.bundle you'll see a method called AppBridge.prototype.messageToApp that constructs a URL starting with the scheme js-frame: and followed by a JSON blob. It then passes these URLs to AppBridge.prototype.loadLocationInTemporaryIframe to be loaded.

With that information in mind we can update our webView:shouldStartLoadWithRequest:navigationType: method.

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    NSURL *url = [request URL];
    NSLog(@"Should load url: %@", url);
    NSString *scheme = [url scheme];
    if ([scheme isEqualToString:@"js-frame"] == YES) {
        // The site is trying to send us a message.
        // Create some data from the url resource specifier
        NSString *resourceSpecifier = [[url resourceSpecifier] stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
        id jsonObj = [resourceSpecifier my_objectFromJSON];
        if (jsonObj && [jsonObj isKindOfClass:[NSDictionary class]] == YES) {
            // If we didn't error and we got a dictionary call receiveMessageFromSite
            [self receiveMessageFromSite:(NSDictionary *)jsonObj];
        }
        // Don't load a js-frame request
        return NO;
    }
    return YES;
}

In the above code we first get the URL scheme and see if it matches our messaging scheme js-frame, if it doesn't then we just return YES to allow the URL to be loaded.

After we determine the URL is one we want to process we get its resourceSpecifier which is just the rest of the URL after the scheme and colon. For our js-frame URLs this should be a JSON blob, so we try and decode it. If it decodes to an NSDictionary we pass it to our receiveMessageFromSite: helper method. We always return NO for js-frame URL requests as we don't want to load any data for them from the network.

Update receiveMessageFromSite: to send a message back to the site when it receives a message.

- (void)receiveMessageFromSite:(NSDictionary *)dict
{
    NSLog(@"RECEIVED MESSAGE FROM SITE: %@", dict);
    [self sendMessageToSite:@{
        @"message" : @"test"
    }];
}

We now have two way communication from our App to the web views content. Give the App a run and you should see a message from the site get output to the console, and the message from the App get added the sites DOM.

Conclusion

We have covered loading a web view with content from our application bundle and one methode of communicating between that content and the App. This approach will work for iOS 5 and above, and if you swap out the JSON serialiation should work on previous versions too.

— Ryan Gibson —