Auto-Layout and UITableView cells

Important note

This post was written before the release of iOS 8. It’s now possible to avoid calculating the height, and let the system figure it out by itself. I encourage you to watch session 226 from WWDC 2014: What’s new in Table and Collection Views (transcript)

Introduction

Auto-Layout. I’ve been very reluctant to use it when it was introduced with iOS 6. I considered it was not worth the learning curve and sticked with classical frame/center positioning. But Apple is really pushing us iOS developers to use Auto-Layout. It has been clear during WWDC 2013.
I must admit that the improvements they made in Xcode 5 are great, so I decided to give it a try before the release of iOS 7.

One of the biggest issues I had is making it work with table views. For “Manual Layout”, there were techniques widely adopted and best practices we were accustomed to. How can they be adapted, using Auto-Layout?

The most popular answer regarding the subject on Stack Overflow gives useful hints but looks incomplete.

Note: the sample project for this post is available on GitHub.

Setting up the cell

The first step is to create the cell. For this post, we will use a nib for our custom cell class CCACustomCell (but everything can be done programmatically).

It will hold a single label of variable height. It’s achieved by:

  • setting its numberOfLines property to 0
  • adding 4 constraints, pinning the label to the 4 edges of the cell’s contentView.

This way, the content view height will fit the label height:

1
Content view height = Top constraint + label height + Bottom constraint

It would of course be possible to achieve a much more complex layout, as long as you set up the constraints properly. The new visual constraints editing system in Xcode 5 makes it really simpler.

Getting the cell height

Our view controller registers the custom cell we just created.

1
2
UINib *cellNib = [UINib nibWithNibName:@"CCACustomCell" bundle:nil];
[self.tableView registerNib:cellNib forCellReuseIdentifier:@"Cell"];

We will use an offscreen extra-cell to make all our height-related stuff. Add CCACustomCell *_stubCell as an ivar.

1
_stubCell = [cellNib instantiateWithOwner:nil options:nil][0];

Then we need to compute the cell height, and making it use the constraints we set up.

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)configureCell:(CCACustomCell *)cell atIndexPath:(NSIndexPath *)indexPath
{
    cell.customLabel.text = _tableData[indexPath.row % _tableData.count];
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    [self configureCell:_stubCell atIndexPath:indexPath];
    [_stubCell layoutSubviews];

    CGFloat height = [_stubCell.contentView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize].height;
    return height + 1;
}

What’s the magic here?

  1. Configuring the content of the cell
  2. Forcing a layout of the cell to apply constraints
  3. Getting the height of the contentView, computed using Auto-Layout. We can’t directly call systemLayoutSizeFittingSize:UILayoutFittingCompressedSize: on the cell because the constraints we’ve set up are relative to the content view. Finally, we use UILayoutFittingCompressedSize to get the smallest size fitting the content.
  4. Adding a bonus 1. I’ve seen a lot of posts on Stack Overflow telling we mysteriously need to add up “some pixels sometimes”. But that’s not mysterious at all. We’ve computed the content view height but we actually need to return… the cell height here. And it’s 1 pixel higher, because of the separator, which height is 1 pt (0.5 for Retina screens on iOS 7, to be exact).

Notice how the label is truncated in the 3rd cell on the left. That’s what can happen without adding the extra 1 pixel.

Performance

While the current implementation is perfectly valid for a low number of rows, it’s a real performance killer when you have dozens of rows.

For this simple cell, it took up to 30s to display the table view for 100,000 rows. And that was on the iOS Simulator, not some old crappy iPhone 3G.

This is because the table view calls tableView:heightForRowAtIndexPath: once per row, to get the total height. And for each row, we’re asking to layout the cell (with Auto-Layout, this means solving a linear equations system).

Fortunately, Apple added tableView:estimatedHeightForRowAtIndexPath: in iOS 7. This allows us to only return a vague estimate for the row height. And we don’t really need to be precise. In our case, something like that is enough:

1
2
3
4
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return 40.f;
}

This way, the table view only calls tableView:heightForRowAtIndexPath: as much as it needs to fill the screen. For the other cells, tableView:estimatedHeightForRowAtIndexPath: is used. And it will compute the real height when needed.

Known issues

I have not been able to get it to work when the cells have an accessory view. The layout is done as if the content view width is equal to the cell width, but it’s not true anymore.

One possible work-around for that is to take the accessory view width into account when you set up the constraints.

While writing this post, I found SmartTables by Jonathan Wight (@schwa), who ended up with a very similar solution. I also encourage you to read Apple’s Auto-Layout Guide.

Reminder: the sample project is available on GitHub.

@Jilouc on Twitter.