App Tour – Part 2: Detecting scrolling to the end of a UIWebView page

by Andy

This is the second of a four part series. The first part discussed dynamically injecting video into a UIWebView.

In the latest version of Taste ZiNG! are a set of pages that offer a tour on the concepts behind ZiNG! and on how to use the app. As an encouragement for reading these pages, I offer a reward in the form of publishing credits1. If you complete a section of the tour, then you receive a free credit.

The tour content is implemented as a set of HTML pages hosted inside a UIWebView control. To earn the credit I wanted the user to actually read the content, so just bringing up the page was not enough, I really wanted to know if they read to the bottom. Now obviously I have no way of knowing if they actually did read (all developers know, to our endless frustration, that users don’t read!), but I could at least hold off on granting the credit until they scrolled to the bottom of the page. I did consider adding a time component to that, but decided that was a bit too much. Maybe next time.

So the problem in hand is how do we know when the user has scrolled to the bottom on a HTML page in a UIWebView control?

Detecting scrolling in HTML is fairly straightforward JavaScript included into each page:

document.addEventListener("DOMContentLoaded",
window.addEventListener("scroll",
function() {
var b = document.body
if (b.clientHeight + b.scrollTop >= b.scrollHeight) {
// ...
}
})
if (b.clientHeight >= b.scrollHeight) {
// ...
}
})
view raw gistfile1.js hosted with ❤ by GitHub

When the document is loaded, set up an event listener for the scroll event, and if the height of the body plus the scrolled offset point is greater or equal than the entire height of the scrollable area, then we’ve reached the bottom. The extra test outside of adding the event listener is to handle cases when the HTML page fits onto a single screen and there is unlikely to be any scrolling.

When we detect the user has reached the bottom of the page, we want to communicate that back to our Objective-C code. The way to do that is to use -webView:shouldStartLoadWithRequest:navigationType: method on the UIWebView delegate. This method is invoked whenever the HTML document is about to navigate to another link, giving a delegate a chance to respond with a YES or a NO. If the method returns a YES the navigation proceeds, but if the response is a NO then the HTML page is not changed.

Initiating navigation in JavaScript is simply a case of setting the window’s location to a new URL. But one of the things we can do is invent our own scheme to differentiate our actions from standard http: links.

if (b.clientHeight + b.scrollTop >= b.scrollHeight) {
    window.location.href='rb://scrolled.bottom'
}

The rb://scrolled.bottom URL is completely made up, it just serves as a signal for our code to perform a specific action.

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
NSString *scheme = [[[request URL] scheme] lowercaseString];
// Allow standard URLs through
NSSet *stdSchemes = [NSSet setWithObjects:@"http", @"file", nil];
if ([stdSchemes containsObject:scheme]) {
return YES;
}
// We do something with the special RB scheme
if ([scheme isEqualToString:@"rb"]) {
if (!delegate) {
return NO;
}
NSString *event = [[[request URL] host] lowercaseString];
if ([event isEqualToString:@"scrolled.bottom"]) {
if ([delegate respondsToSelector:@selector(webPageScrolledToBottom:)]) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){
[delegate webPageScrolledToBottom:_fileName];
});
}
}
}
return NO;
}
view raw gistfile1.m hosted with ❤ by GitHub

In the shouldStartLoadWithRequest method, we allow the standard http and file requests through by returning YES. (The file: requests need to be handled because the HTML pages link to stylesheet and javascript files in the app bundle). But if we see the rb: scheme then we need to decode the rest of the URL to determine what particular action to perform, in this case that we’ve scrolled to the bottom of the page.

One thing that we need to be careful with is that the shouldStartLoadWithRequest method should return quickly, not only from a user experience point of view but also because the UIWebView control only waits 10 seconds for a response before it gives up and continues. Therefore as soon as we determine a delegate is interesting in knowing that we’ve scrolled to the bottom then we use Grand Central Dispatch to push the invocation on to an asynchronous queue and inform the UIWebView to stop loading.

With an event listener, the UIWebView signals a scroll to the bottom by initiating a navigation, that navigation is intercepted and refused, but not before signaling back up the view hierarchy. In this specific instance of the tour, I ultimately record the scroll event in a NSUserDefaults item, but the aspects of that little detail are not relevant to this post.

  • Part 1: Dynamically injecting video into a UIWebView
  • Part 3: Handling full screen video
  • Part 4: Mimicking instance variables for a category
  1. In Taste ZiNG! you can publish your wine and food information to the cloud, the monthly cost for that publishing is handled through an in-app currency called credits.