Hybrid mobile apps: Navigation

This is part 2 of a series of posts about the conversion of OneSchool's fully native app to a hybrid HTML/native app. Read part 1.

When convering part of an existing native app to HTML, the most important part is making sure the new HTML parts fit seamlessly with the existing native code. To do so, we will employ some clever tricks involving custom schemes.

Custom URL schemes

The scheme of a URL is the part of it at the very beginning, before the "://". Almost all links on the Web have the scheme "http" or "https". When a Web browser encounters one of these scheme, it knows to request a page from the specified host and path using the HTTP protocol. We want to do things much more advanced than this, however, so we will need to define a custom scheme. We will define two such schemes:

  • tt:// refers to fully native views
  • os:// refers to Web views loaded within our native wrapper

If a normal browser were to encounter either of these custom schemes, it would be unable to load them. However, since we fully control the UIWebView browser, we will be able to define custom behavior for them.

Client-side URLs

Before we continue, we need to make sure all of the fully native views have URLs assigned to them. If you're already using Three20's URL-based navigation, you should already be good to go. If not, the easiest way to proceed is to start using it, though it is possible to work around not having it. In the end, all you need is a globally accessible way to map URL strings to view controller instances. The Three20 TTNavigator is the most elegant way, but for simple apps that don't want the rest of the bulk of Three20, a series of if/else blocks can suffice. For demonstration purposes, I'll be using Three20.

The WebController class

WebController will be the class that wraps our HTML views and allows them to enter the native navigation stack. The first step is to add this controller to our native URL system:

    [map from:@"tt://web/(initWithPath:)" toViewController:[WebViewController class]];  

This definition maps all URLs starting with the prefix "web/" to the WebController, while passing the rest of the URL to it as the argument to initWithPath. Let's proceed by defining this method:

- (id)initWithPath:(NSString*)initialPath {
    if (self = [super init]) {
        path = initialPath;
    }
    return self;
}

Simple enough; it simply saves the passed path. The loadView method is slightly more complex:

- (void)loadView{
    [super loadView];
    webView = [[UIWebView alloc] initWithFrame:self.view.frame];
    [self.view addSubview:webView];
    webView.delegate = self;
    NSString* webPath = [NSString stringWithFormat:@"http://localhost:8080/%@", path];
    [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:webPath]]];
}

After setting up the UIWebView, we format the actual Web path to load. We're appending the path passed to the WebController to "http://localhost:8080". So a navigation to the native URL "tt://web/some/path" would result in the Web URL "http://localhost:8080/some/path" being loaded and shown in the UIWebView. Now, when a native view wants to load an HTML view, it can simply navigate to the corresponding native URL.

HTML linking

But what about the reverse? How can an HTML view link to a native view? The answer lies in the custom schemes we defined earlier. When initializing the UIWebView, we set its delegate to our WebController instance. To add custom behavior to our custom scheme, we can define the delegate method:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request 
navigationType:(UIWebViewNavigationType)navigationType{
    NSURL* url = request.URL;
    if([url.scheme isEqualToString:@"tt"]){
        [[TTNavigator navigator] setURL:url.absoluteString];
return NO;
}
    if([url.scheme isEqualToString:@"os"]){
        NSString* nativePath = [url.host stringByAppendingString:url.path];
        [[TTNavigator navigator] setURL:[NSString stringWithFormat:@"tt://web/%@", nativePath]];
return NO;
    }
    return YES;
}

This delegate method is called every time the UIWebView is about to respond to a link being pressed. Here, we can check whether the URL's scheme is one of our custom schemes. If the scheme is "tt://", we dispatch the request to the Three20 navigator, which pushes the appropriate native view controller onto the navigation stack. If it is "os://", we do the same, except we push another WebController. NSURL will interpret a URL like "os://this/is/a/path" as having a host of "this" and a path of "/is/a/path", so we concatenate the host and path. Then, we append this onto "tt://web/" to get the full native path for another WebController with the requested Web path.

Now, when the HTML view want to link to a native view, it can simply link to "tt://some/native/path". When it wants to link to another Web view, it can link to "os://some/web/path".

If the URL had one of our custom schemes, we return NO to stop the UIWebView from actually trying to load our custom-scheme URL. Otherwise, we return YES to allow it to carry out default behavior.

HTTP links

Consider what would happen if an HTML view included a link with an "http" scheme, like "http://www.google.com". Our custom handlers would not catch it, and the UIWebView would navigate to the requested site. But because this was a UIWebView transition and not a UINavigationController transition, there's no way for the user to get back! When using embedded Web views in this manner, you must be very careful to not use normal "http" links unless you include another "http" link on the linked page to get back. In a later post, I'll discuss ways to prevent "http" links from navigating the UIWebView altogether after the initial load of the page.

Putting it all together

Let's convert the OneSchool bus tracker section to HTML using these techniques. Here are the current fully-native URL mappings:

    [map from:@"tt://launcher" toSharedViewController:[LauncherController class]];
    [map from:@"tt://bus" toSharedViewController:[BusTrackerController class]];
    [map from:@"tt://map/bus/(initWithInitialBus:)" toSharedViewController:[MapController class]];

The LauncherController has an icon that references the native URL "tt://bus", and the BusTrackerController loads a list of buses from the server and displays them with links of the form "tt://map/bus/<number>". The launcher and map controller both use rich formatting that we'll leave native for now, but the view in between is a simple list that is a perfect candidate for becoming HTML.

First, we change the link in the launcher to "tt://web/bus". This links to a WebController that will load the "bus" page from our Web server. We'll set up the HTML as follows (using Django template syntax):

<ul>
    {% for route in data %}
        <li><a href="tt://map/bus/{{ route.id }}">{{ route.name }}</a></li>
    {% endfor %}
</ul>

Each of the route list items will link to the existing native controller to display the route on the map. We can now delete the obsolete BusTrackerController as well as its mapping from our native URL list.

Now let's say we've also rewritten the map controller in HTML. We can change the route list HTML to:

<ul>
    {% for route in data %}
        <li><a href="os://bus/{{ route.id }}">{{ route.name }}</a></li>
    {% endfor %}
</ul>

The route list items now link to another WebController that will load the "bus/<id>" page from our Web server.

Read on to part 3, where I discuss using HTML forms and AJAX within the embedded Web view.