UIScrollCeption: embedding multiple UIWebViews in a UITableView

You don’t have that much choice to display HTML content with basic UIKit components. You’re basically limited to UIWebView and… nothing else.

It’s a mini Safari, so it does the job very well. But there’s WebKit under the hood so it’s quite heavy: it has a significant memory trace and takes a while to load.

At this point, you need to think about what the HTML looks like. There are some great open-source components out there to display rich-text:

  • DTCoreText can create attributed strings from HTML and takes care of the rendering. It supports rich text, images, videos, transforms and much more. Really great.
  • OHAttributedLabel or TTTAttributedLabel both support basic formatting stuff.

If you generate the HTML content yourself and have a complete control over it and if it matches the features of one of these components, go ahead and use it.
However, if you have arbitrary HTML, maybe including some pieces of Javascript, advanced styling: you’re stuck with UIWebView.

And now…

What if you want to display a list of arbitrary html-based content?

UITABLEVIEW ALL THE THINGS

iOS developers have grown a conditioned reflex. When they hear “list”, they think UITableView. And this is good, because it has everything we need:

  • Native scroll for free
  • Memory-efficient cells thanks to the reuse pattern

Thanks to the reuse of table cells, we can have a limited number of concurrent UIWebView. They’re heavy, but having 3, 4 or 5 of them won’t kill the app.

You’re now in the process of writing a custom UITableViewCell embedding a web view, returning it from tableView:cellForRowAtIndexPath:. Nice. The next logical step is tableView:heighForRowAtIndexPath:

1
return 42.f; // Errrr

Here comes the problem. The table view needs to know the height of all the cells when updating its content.

  • How to compute the content height in a UIWebView?
  • How to update the table view accordingly?

All further Objective-C code:

  • is targeted at iOS 5 and later
  • is using ARC
  • is using new llvm features. No need to type @synthesize … anymore.

Computing the cell’s height

Let’s say you want to display the answers to this question on Stack Overflow: Setting custom UITableViewCells height.

The HTML code for an answer looks like (got through this API call)

1
2
3
4
5
6
7
8
9
<p>Your <code>UITableViewDelegate</code> should implement <code>tableView:heightForRowAtIndexPath:</code></p>

<pre><code>- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [indexPath row] * 20;
}
</code></pre>

<p>You will probably want to use <code>NSString</code>'s <code>sizeWithFont:constrainedToSize:lineBreakMode:</code> method to calculate your row height rather than just performing some silly math on the indexPath :)</p>

Content model

Nothing too complex here. We’re representing an answer by an instance of MyContent, holding the answer id and its body.

MyContent.h
1
2
3
4
5
6
7
8
9
#import <Foundation/Foundation.h>

@interface MyContent : NSObject

@property (nonatomic, assign) NSInteger identifier; // answer identifier
@property (nonatomic, strong) NSString *body; // HTML content
@property (nonatomic, assign) CGFloat cellHeight;

@end

The HTML page template

We’re going to wrap the content inside a custom template, designed to compute the height of the document.

content_template.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8">
        <meta name = "viewport" content = "width=device-width, initial-scale=1.0, maximum-scale=5.0">

        <script type="text/javascript" src="jquery-1.7.2.min.js"></script>
  </head>
  <body>[[
      
      [[content_body]]
      
      <script type="text/javascript">
      
        $(function() {
              
            if (![[should_monitor_size]]) {
              return;
            }
      
            $(document).resize(function() {
                window.location.href = "ready://content/[[content_id]]/" + $(document).outerHeight(true);
            });

            window.location.href = "ready://content/[[content_id]]/" + $(document).outerHeight(true);

        });
      </script>
  </body>
</html>

The important bits are in the <script> tag, between lines 13 and 21. These few lines of Javascript^1 allows you to be notified when the document has been loaded or resized. By setting the window.location.href property to a custom URL, it triggers the webView:shouldStartLoadRequest:navigationType: delegate call.

The templated elements are:

  • [[content_body]]: will be replaced by the answser’s body
  • [[content_id]]: will be replaced by the answer id
  • [[should_monitor_size]]: will be true if the height has already been computed, false if you already have the height cached somewhere and don’t need recomputation.

Table view delegate

tableView:cellForRowAtIndexPath: returns a reusable cell if available.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {

    MyContent *content = [self _contentForIndexPath:indexPath];

    static NSString *cellId = @"ContentCell";

    MyCustomTableViewCell *cell = (MyCustomTableViewCell *)[tableView dequeueReusableCellWithIdentifier:cellId];
    if (!cell) {
      cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault
                                    reuseIdentifier:cellId];
    }

    [cell updateWithContent:content];

    return cell;
}

The tableView:heightForRowAtIndexPath: returns the computed height if available. I chose 0 as default value until the height is computed.

1
2
3
4
5
6
7
8
9
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {

    MyContent *content = [self _contentForIndexPath:indexPath];

    if (content.cellHeight) {
        return post.cellHeight;
    }
    return 0.f;
}

Now let’s put everything up together in a custom cell.

Custom UITableViewCell setup

Create a subclass of UITableViewCell. It will of course contain a UIWebView. The cell is the webview’s delegate.

MyWebTableViewCell.h
1
2
3
4
5
6
7
8
9
10
11
12
#import <UIKit/UIKit.h>

@class MyContent;

@interface MyWebTableViewCell : UITableViewCell <UIWebViewDelegate>

@property (nonatomic, strong) UIWebView *webView;
@property (nonatomic, strong) MyContent *content;

- (void)updateContent:(MyContent *)content;

@end
MyWebTableViewCell.m
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#import "MyWebTableViewCell.h"
#import "MyContent.h"

@implementation MyWebTableViewCell

- (id)initWithStyle:(UITableViewCellStyle)cellStyle
    reuseIdentifier:(NSString *)reuseIdentifier
{
    self = [super initWithStyle:cellStyle
                reuseIdentifier:reuseIdentifier];
    if (self) {
        self.webView = [[UIWebView alloc] initWithFrame:self.bounds];
        [_webView setDelegate:self];

        // It's important that the webview doesn't autoresize when its parent's frame changes.
        [_webView setAutoresizingMask:UIViewAutoresizingNone];

        [_webView.scrollView setScrollEnabled:NO]; // Prevents scrolling in the webview.
        [_webView.scrollView setScrollsToTop:NO]; // Keep the "scroll to top when the status is tapped" behavior.

        [self.contentView addSubview:_webView];
    }
    return self;
}


- (void)updateWithContent:(MyContent *)content {

    self.content = content;

    NSString *template = [NSString stringWithContentsOfFile:RESOURCE(@"content_template", @"html")
                                                   encoding:NSUTF8StringEncoding
                                                      error:nil];

    template = [template stringByReplacingOccurrencesOfString:@"[[should_monitor_size]]"
                                                   withString:content.cellHeight != 0 ? @"false" : @"true"];

    template = [template stringByReplacingOccurrencesOfString:@"[[content_id]]"
                                                   withString:[NSString stringWithFormat:@"%d", content.identifier]];

    template = [template stringByReplacingOccurrencesOfString:@"[[content_body]]"
                                                   withString:content.body];

    // Finally, load the content
    [self.webView loadHTMLString:template
                         baseURL:[[NSBundle mainBundle] bundleURL]];


}

- (void)webViewDidStartLoad:(UIWebView *)webView {
    webView.alpha = 0.f;
}

- (void)webViewDidFinishLoad:(UIWebView *)webView {
    webView.alpha = 1.f;
}

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {

    // Serious things here, as explained in details below.

}

- (void)layoutSubviews {
    [super layoutSubviews];

    // Manually resize the web view
    CGRect r = self.webView.frame;
    r.origin = CGPointZero;
    r.size = self.frame.size;
    self.webView.frame = r;
}

@end

As mentioned earlier, every time the document is loaded or resized, it will call the delegate’s webView:shouldStartLoadWithRequest:navigationType:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {

    NSURL *url = [request URL];
    NSString *scheme = [url scheme];

    if ([scheme isEqualToString:@"ready"]) {

        // URLs look like ready://content/12345/232 
        //                     content id --^    ^---- document height

        NSInteger contentId = [[[url pathComponents] objectAtIndex:1] integerValue];

        if (self.content.identifier != contentId) { // sanity check
            return NO;
        }

        NSInteger height = [[[url pathComponents] objectAtIndex:2] integerValue];

        if (height != self.content.cellHeight) {
            self.content.cellHeight = height;

            // Magic. Empty update block will animate the cell to it's new height!

            UITableView *tv = (UITableView *)self.superview;
            [tv beginUpdates];
            [tv endUpdates];

        }

        return NO;
    }

    return YES;
}

Summary

  • Create cells with default height
  • Load the content in the web view
  • Listen to load and resize events using Javascript
  • Update the height of the cell
  • There is no step 5.

I use this technique to build the “Answers” screen in my Stack Exchange client SOStacked.

I’m @Jilouc on Twitter if you have remarks or suggestions.

 


^^1. jQuery is included to easily get the height of the document once it’s loaded.