Harry Richardson

How I Optimise Table View Performance With Dynamic Cell Heights

| Comments

Dynamic cell heights in UITableView have put a big spanner in the works in terms of performance. You can’t simply return an arbitrary height under heightForRowAtIndexPath and assume everything will work. If you have text in your cell that could be 1 line or 10 lines, your cells need to have a dynamic height. There are a couple of ways to measure this height. I’ll get into that shortly. Measuring cell heights can also be a relatively slow process, so I’ll also go into optimisation techniques to achieve the all-important 60 frames-per-second that everyone strives for.

To jump straight to a project with all the final source code, go here

The Basics

With auto-layout being pushed by Apple as the primary method of laying out your views, it certainly makes sense to measure cells using it as well. To do this you would create a method similar to the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- (CGFloat)heightForRowWithModel:(id)model {
      // 1  
    static Cell *prototypeCell = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        prototypeCell = [self.tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    });

    // 2
    [prototypeCell configureForHeightWithModel:model];
    // 3
    prototypeCell.bounds = CGRectMake(0.0f, 0.0f, CGRectGetWidth(self.tableView.bounds), CGRectGetHeight(prototypeCell.bounds));
    [prototypeCell layoutIfNeeded];
    // 4
    CGSize size = [prototypeCell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
    CGFloat actualHeight = size.height + 1;
    return actualHeight;
}

There’s a couple of things to mention here:

  1. There is an offscreen cell that I’m using to measure heights with. There would need to be one of these for each identifier/model combination.
  2. The cell is configured using a height-specific method; this is so nothing is set that doesn’t need to be set. If you know the size of an image view, you don’t need to load the image. Stick with what can change, such as multi-line labels.
  3. Set the size of the cell itself: remember, the cell is offscreen, but we need it to be the size that it will be when on the screen. For example, if the user is in landscape mode, the width of the cell needs to be altered accordingly.
  4. Call systemLayoutSizeFittingSize method on the contentView view on the cell. Doing this at the cell level won’t layout the content view correctly. Do it on the content view and then add the +1 to get the size of the cell (remember, the content view height is 1 less than the cell).

In the Cell class itself the layoutSubViews method needs to be overridden in a way similar to this:

1
2
3
4
5
6
7
8
- (void)layoutSubviews {
    [super layoutSubviews];

    [self.contentView layoutIfNeeded];

    // All labels need to have their preferredMaxLayoutWidth set (if iOS 7 is being supported)
    self.label.preferredMaxLayoutWidth = CGRectGetWidth(self.label.frame);
}

The contentView is also laid out because we changed the bounds on Cell and now we need to force the layout on contentView. Of course we also need to set the preferredMaxLayoutWidth on label.

This is pretty basic stuff when it comes to dynamic cell heights in iOS 7 and above. Notice how I haven’t done any optimisations at all, including implementing estimatedHeightForRowAtIndexPath. This is intentional, I wanted to keep the part about measuring totally separate to everything else.

Optimisations

So, the biggest optimisation is, of course, implementing estimatedHeightForRowAtIndexPath. Most people recommend not doing any calculations in this method as it will be called on every row in the table view. Remember, heightForRowAtIndexPath is only called for the visible rows.

Performance at this point may be enough. We’re trying to get as close to 60 FPS while scrolling on old devices (for me, this is the iPhone 4S and iPad Mini) as possible. If you’re not hitting that magical 60 FPS, it’s worth continuing the optimisation.

The next step is to cache cell heights for your table view.

First of all, if your models have a tendency to change, it would be a good idea to override hash and isEqual. To explain: the idea is that we use the model’s hash as a key in a dictionary, and the cell height as the value. If your data is changing throughout, that cached height needs to change as well because the data could affect it.

It’s actually quite easy to implement at this point, a mutable dictionary property needs to be added to the class, and then in the heightForRowWithModel method, a few lines need to be added. The method will now become:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (CGFloat)heightForRowWithModel:(id)model {
    NSNumber *key = [model hash];

    NSNumber *height = self.cachedHeights[key];
    if (height) {
        return [height floatValue];
    }

    static Cell *prototypeCell = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        prototypeCell = [self.tableView dequeueReusableCellWithIdentifier:CellIdentifier];
    });

    [prototypeCell configureForHeightWithModel:model];
    prototypeCell.bounds = CGRectMake(0.0f, 0.0f, CGRectGetWidth(self.tableView.bounds), CGRectGetHeight(prototypeCell.bounds));
    [prototypeCell layoutIfNeeded];
    CGSize size = [prototypeCell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize];
    CGFloat actualHeight = size.height + 1;
    self.cachedHeights[key] = @(actualHeight);
    return actualHeight;
}

This data can be persisted in any way you see fit; it doesn’t have to just be a dictionary. I will point something out though: the cache will either need to be erased when the device orientation changes, or when the cell bounds change. In my own apps I opt to just override willRotateToInterfaceOrientation and erase the cache there. It’s entirely up to you how you deal with this. You could even cache the heights for each width of the table view - for example, have a dictionary of dictionaries, with the outer dictionary’s keys being the table view width.

The next step is to re-implement estimatedHeightForRowAtIndexPath with our new cached values. We no longer need to return an arbitrary value as we now have a cached value available to us. This won’t necessarily increase performance, but given that there will occasionally be a slight “jump” when slowly scrolling a table view that hasn’t completely measured all row heights, this is great for user experience.

Here’s an example of the method implementation:

1
2
3
4
5
6
7
8
9
- (CGFloat)estimatedHeightForRowWithModel:(id)model {
    NSNumber *key = [model hash];
    NSNumber *height = self.cachedHeights[key];
    if (height) {
        return [height floatValue];
    } else {
        return 70.0f;
    }
}

Easy!

What if you still haven’t reached 60 FPS while scrolling? Well, there’s still more we can do!

One of the major complaints of auto-layout is that it can be pretty slow when laying out a lot of views - exactly what we’re doing when scrolling. Given that we only want to layout for height, laying out the entire content view of a cell maybe slightly overkill. So what to do instead? We go old school! We can still use auto-layout on the cell, but when it comes to measuring, the text should be measured manually, and all constraints added in and used. If I’ve created the cell in a XIB, I’ll generally add an IBOutlet for each height-related constraint and use the constant property instead of using a fixed value in code. After all, I may decide to change the margin sizes in the future.

The biggest problem doing this is with measuring text. I created a category on NSString that takes care of this for me. Here’s the implementation:

1
2
3
4
5
6
7
8
9
10
- (CGSize)sizeConstrainedToSize:(CGSize)constrainedSize usingFont:(UIFont *)font withOptions:(NSStringDrawingOptions)options lineBreakMode:(NSLineBreakMode)lineBreakMode {
    NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
    paragraphStyle.lineBreakMode = lineBreakMode;

    NSAttributedString *attributedText = [[NSAttributedString alloc] initWithString:self attributes:@{NSFontAttributeName: font, NSParagraphStyleAttributeName: paragraphStyle}];
    CGRect rect = [attributedText boundingRectWithSize:constrainedSize options:options context:nil];
    CGSize textSize = CGSizeMake(ceilf(rect.size.width), ceilf(rect.size.height));

    return textSize;
}

What this is doing is converting the NSString into an NSAttributedString to do its measuring. If I’m honest I’m not actually sure if this is the most performant way of measuring text, but so far it’s achieved everything I need so I’ve not had to look for alternative solutions.

This is used with the following method call:

1
2
3
4
[text sizeConstrainedToSize:CGSizeMake(label.frame.size.width, CGFLOAT_MAX)
                                    usingFont:label.font
                                  withOptions:options
                                lineBreakMode:NSLineBreakByWordWrapping].height;

There will obviously be times where your UILabel will have a maxNumberOfLines that isn’t 0. In this case the CGFLOAT_MAX should be changed with a value that is similar to the following:

1
ceilf(label.font.lineHeight * label.numberOfLines);

There are plenty of other optimisations you can do, such as reducing the number of shadows in a cell, but these kind of things aren’t related to cells with dynamic heights so I won’t include them here.

To view the code for this post please go here

Comments