Introduction

5 Years of Tables

If you're an iOS developer, you're a UITableView developer. Since the inception of iOS the UITableView has been at the crux of its visual design. Although UITableView best practices are often a point of contention among the developer community, the ability to master cell reuse and create efficient, performant and beautiful table views is key to the success of any iOS architect.

With the addition of UICollectionView in iOS 6, Apple introduced a much needed twin to UITableView with broader applications and flexibility. Apple later demonstrated the power and versatility of UICollectionView with the Photos app for iOS 7.

Yet there was still an elephant in the room. Due to the way UITableView (and its younger brother UICollectionView) functioned before iOS 8, it was inefficient and difficult to work with cells that are dynamically sized, especially when using Auto Layout.

Time to Adapt

Apple has made it clear that the latest and greatest version of iOS is all about flexibility. This is most evident in how the concept of interface orientation:

willRotateToInterfaceOrientation(toInterfaceOrientation: UIInterfaceOrientation, duration: NSTimeInterval)

has been replaced by more generic size handling:

viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator)

— just one example of how iOS 8 has been rewritten from the core to harmonize with any new screen size.

When you create a XIB in Xcode 6's Interface Builder, it now takes size "Freeform" by default. Storyboards can now easily switch between multiple screen sizes. Apple is strongly encouraging developers to adopt Dynamic Type in all new apps for iOS 8. What was once a single 320x480 iPhone has now grown into 4 different iPhone sizes and 2 different iPad sizes, encompassing 3 levels of pixel density (the iPhone 6 Plus accepts @3x images).

This new "dynamism" demands more flexibility out of the ancient UITableView API. It's more important than ever that your cells are intelligent and adaptive to any width and height available. Luckily with the release of iOS 8 Apple has finally given us what we deserved from UITableView. In this post I'll demonstrate how iOS 8 solves all of your dynamic table view cell sizing needs.

Before iOS 8

The Problem

So what exactly was wrong with UITableView before iOS 8? It required to know the height of a cell before instantiating the cell itself. UITableView before iOS 8 was actually audacious enough to call

tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat

to its UITableViewDelegate for every single cell in the table, whether you had five or five-thousand, as soon as the view appeared on the screen. The table view, a UIScrollView subclass, needed to determine its contentSize accordingly, and decided to rely on you alot for that calculation.

It's easy to imagine the challenge this causes when every cell has a different height. Calculating hundreds or thousands of row heights for cells that don't even exist yet is downright silly.

But wait! In iOS 7 Apple gave us a new method:

tableView(tableView: UITableView, estimatedHeightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat

The table view used this method to calculate its contentSize up front, and deferred calling heightForRowAtIndexPath until the cell was about to be displayed.

This was a step in the right direction, but it only revealed a deeper issue. Up until now, UITableView did not support Auto Layout in any straightforward, meaningful way. Giving a cell valid constraints, and by extension a valid intrinsicContentSize, meant nothing to the table view entrusted to display it. Although Auto Layout is clearly the best way to ensure visual flexibility in a table cell, we were still stuck with returning a concrete CGFloat value to the UITableView instead of letting Auto Layout do what it does best.

The Old Solutions

A common solution to the dynamic row height problem employs the use of class methods on a custom UITableViewCell subclass which return the appropriate height based on the data you know the cell might eventually use (if it's ever displayed). For example:

class func heightWithArticle(article: Article) -> CGFloat

Ok, that's easy. We can return this value in heightForRowAtIndexPath since we have an NSIndexPath to correlate with our data source. However, this approach gets more complicated when additional visual logic enters the playbook:

class func heightWithArticle(article: Article, expanded: Bool) -> CGFloat

And worse still when handling a dynamic width as well:

class func heightWithArticle(article: Article, expanded: Bool, forCellWidth: CGFloat) -> CGFloat

Before long these class methods became longer than the iBook for the Swift Programming Language and keeping track of which one to call was a nightmare, among other downsides:

  • Tricky to use Auto Layout in this approach, since class method sizing code must be synchronous with the actual constraints without having access to them
  • Class method sizing code is duplicated and therefore less performant
  • Challenging when working with Attributed labels
  • Code smell

John Szumski also outlines an Auto Layout friendly approach in his article Auto Layout for Table View Cells with Dynamic Heights. This approach involves maintaining a separate instance of UITableViewCell that's only used for sizing. On each iteration of heightForRowAtIndexPath you fill it with the data that will be displayed for the given NSIndexPath and call systemLayoutSizeFittingSize(UILayoutFittingCompressedSize) to return the proper height. This approach, although cleaner, still has a few downsides:

  • Must configure cell twice
  • Layout engine is doing double duty
  • Must use main thread to calculate height

iOS 8 Magic

Rejoice, for you no longer have to write lengthy class methods or call auto layout directly to dynamically size your UITableView rows!

iOS 8 introduces a new in/out concept:

  1. Cell is sent a resolved width when it is created/dequeued using one of the two methods: systemLayoutSizeFittingSize(targetSize: CGSize) -> CGSize when using Auto Layout sizeThatFits(size: CGSize) -> CGSize without Auto Layout
  2. Cell returns a resolved height. Auto Layout will happily return this resolved height for you. <-- that's the magic

Important Notes

  • Constraints must be on the cell's contentView.
  • The new default rowHeight property for UITableView is UITableViewAutomaticDimension. If you were setting it before, make sure you return it to this new default, and remove any calculations you based from it since it's no longer a logical number.
  • Like iOS 7, you can still provide the estimatedHeightForRowAtIndexPath method or estimatedRowHeight property to help the table view make a more accurate guess for contentSize on load.
  • If your cell's instrinsicContentSize changes for any reason, you must reload the table for it to update the size accordingly. You can animate this change with methods such as reloadRowsAtIndexPaths(_ indexPaths: [AnyObject], withRowAnimation animation: UITableViewRowAnimation)

Examples

Since the textLabel and detailTextLabel properties of UITableViewCell already have valid constraints, they can be used out of the box with this approach. The simple code below pulls from a list of reviews of the Google Maps app for Android and displays them in a UITableView:

override func numberOfSectionsInTableView(tableView: UITableView!) -> Int {
 return 1
}

override func tableView(tableView: UITableView!, numberOfRowsInSection section: Int) -> Int {
 return reviews.count
}

override func tableView(tableView: UITableView!, cellForRowAtIndexPath indexPath: NSIndexPath!) -> UITableViewCell! {
 let cell: UITableViewCell = tableView.dequeueReusableCellWithIdentifier(CellIdentifier, forIndexPath: indexPath) as UITableViewCell

 cell.textLabel.text = reviews[indexPath.row] as? String
 cell.textLabel.numberOfLines = 0;

 return cell
}

Notice there is no heightForRowAtIndexPath method, and I did not set a rowHeight property on the table. This is what happens when executed:

iOS 7 (left) vs iOS 8 (right)
iOS 7 no sizing
iOS 8 sizing

Simply telling the textLabel that its numberOfLines = 0 causes it to assume a multiline layout. Then when the layout engine calls intrinsicContentSize() -> CGSize on the cell, the proper label height is passed on to the UITableView to size the row perfectly.

Custom Cells

What about using a custom UITableViewCell subclass? To expand our review UI, we'll start with defining a title label, body label and image in our init method.

titleLabel = UILabel()
bodyLabel = UILabel()
contactImageView = UIImageView()

super.init(style: style, reuseIdentifier: reuseIdentifier)

Then we can configure these subviews. We want translatesAutoresizingMaskIntoConstraints to be false since we will be defining the constraints ourselves. Remember that we must add them to the contentView for the iOS 8 auto-sizing logic to work.

titleLabel.setTranslatesAutoresizingMaskIntoConstraints(false)
titleLabel.font = UIFont.boldSystemFontOfSize(16.0)
titleLabel.numberOfLines = 0
self.contentView.addSubview(titleLabel)

bodyLabel.setTranslatesAutoresizingMaskIntoConstraints(false)
bodyLabel.font = UIFont.systemFontOfSize(14.0)
bodyLabel.numberOfLines = 0
self.contentView.addSubview(bodyLabel)

let contactImage: UIImage = UIImage(named: "contact_image")

contactImageView.setTranslatesAutoresizingMaskIntoConstraints(false)
contactImageView.image = contactImage
self.contentView.addSubview(contactImageView)

To define the constraints I'm using a category that I created on NSLayoutConstraint to apply multiple Visual Format Language strings quickly and easily.

self.contentView.addConstraints(
 NSLayoutConstraint.constraints(
 views: [ "title" : titleLabel, "body" : bodyLabel, "image" : contactImageView ],
 visualFormats:
 "H:|-5-[image(40)]-5-[title]-5-|",
 "H:|-5-[image]-5-[body]-5-|",
 "V:|-5-[title]-5-[body]-(>=5)-|",
 "V:|-5-[image(41)]-(>=5)-|"))

Now that we implemented our custom cell, we just need to register and return it to the UITableView for display:

override func tableView(tableView: UITableView!, cellForRowAtIndexPath indexPath: NSIndexPath!) -> UITableViewCell! {
 let cell: ReviewCell = tableView.dequeueReusableCellWithIdentifier(CellIdentifier, forIndexPath: indexPath) as ReviewCell

 let dict: NSDictionary = reviews[indexPath.row] as NSDictionary
 cell.title = dict["title"] as? String
 cell.body = dict["body"] as? String

 return cell
}

As you can see below, iOS 8 is doing all of the heavy lifting to determine our row heights. As long as we have valid constraints, that's the end of the story.

Custom Cell Sizing

Collection Views

If you're using UICollectionViewFlowLayout, there's almost no work to do. Just replace the itemSize property on your UICollectionView or the

collectionView(collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: NSIndexPath) -> CGSize

method on UICollectionViewDelegateFlowLayout with the new estimatedItemSize property and that's it! This will cause the collection view to start using each cell's constraints to determine size just like in UITableView.

How It Works

  1. The layout computes the first approximation of the contentSize and sends this to the UICollectionView.
  2. The cells are created and self-sized by the UICollectionView
  3. The layout receives the updated size attributes from the UICollectionView
  4. The layout returns the final attributes to the `UICollectionView
  5. The UICollectionView uses these attributes to display the cells.

Even More Control

But wait, there's more. Collection views take it even further than table views. With the new

preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes!

method on UICollectionReusableView, you can control everything from transform to alpha based on the pre-determined attributes (like size) given to you from the layout engine.

Note: UICollectionReusableView is the superclass of UICollectionViewCell

By overriding this method in your subclass and returning a UICollectionViewLayoutAttributes instance you can control multiple visual attributes for cells, supplementary views and decoration views. For example, you can set a cell to hidden on the fly:

override func preferredLayoutAttributesFittingAttributes(layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes! {

 if self.shouldBeHidden(layoutAttributes) {
 layoutAttributes.hidden = true
 } else if self.selected {
 layoutAttributes.transform = CGAffineTransformScale(CGAffineTransformIdentity, 0.8, 0.8)
 }

 return layoutAttributes
}

In this example, the collection view won't render the cell if hidden is true. And if the cell is selected, a scale transform will be applied. Pretty nifty, huh?

Performance

Considering the extra layout work that UITableView performs on iOS 8, how does this impact scrolling performance? To compare, I set up an experiment where I performed 3 sets of 5 high-speed scrolls for each different dynamic sizing approach. The table displayed 500 variable-height ReviewCell instances on a 4th-generation iPad. After extracting the frames per second using the Core Animation profiler, I averaged them all together into a single FPS value.

Performance Chart

It turns out that even though iOS 8 is doing some extra work when it comes to layout, there are trivial effects on frame rate. Keep in mind that with the first 2 approaches the UITableView is receiving the row heights for all of the cells on load and therefore gets its contentSize for free. With the iOS 8 approach the UITableView is forced to calculate row heights while scrolling and simultaneously adjust the contentSize based on those heights. Even with this added layout calculation there is no perceptible decrease in performance.

To Infinite Sizes and Beyond

With iOS 8 Apple handed us the most powerful UITableView and UICollectionView APIs yet. There's no longer an excuse for hard-coding cell sizes when cell content is variable. With the marriage of Auto Layout and the new UITableView in iOS 8 you can feel confident that your interface will adjust perfectly in any setting, whether it's an iPhone 6 or even an Apple Watch. If you're targeting iOS 8 and above, go forth and delete your heightForRowAtIndexPath methods and never look back.