UITableView & UICollectionView: Killing stringly typed cells with Swift

12 min read
iOSSwiftUIKit

UITableView & UICollectionView are the bread and butter of many iOS applications. However, dequeuing cells with string identifiers can result in brittle code that doesn't scale as table complexity increases. We're going to look at using Swift's type system to get rid of the ad-hoc typing we get from using string identifiers and eliminate the need for type casts in the process.

Introduction

It's helpful to look at the original context in which an API was used in order to understand the problems it solves. To many of us the following code will be all too familiar.

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    static NSString *CellIdentifier = @"MyCellIdentifier";
    MyCell *cell = (MyCell *)[tableView dequeueReusableCellWithIdentifier: CellIdentifier];

    if (cell == nil) {
        cell = [[MyCell alloc] initWithStyle: UITableViewCellStyleDefault reuseIdentifier:CellIdentifier];
    }

    // Do stuff with cell

    return cell;    
}

This convention has not conceptually changed since its introduction. The code is highly localized: the definition of the identifier, the dequeuing of the cell, and the allocation of a cell with a given identifier are all in one place, making it easy to reason about. This also makes the casting of the cell's type seem innocuous.

As apps become increasingly complex, UITableViewDelegates often started displaying multiple data types, leading to patterns like the following:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell;

    if (indexPath.section == 0) {
        static NSString *CellIdentifier = @"MyCellIdentifier";
        MyCell *myCell = (MyCell *)[tableView dequeueReusableCellWithIdentifier: CellIdentifier];

        if (myCell == nil) {
            myCell = [[MyCell alloc] initWithStyle: UITableViewCellStyleDefault reuseIdentifier: CellIdentifier];
        }

        // Do stuff with cell

        cell = myCell
    }
    else {
        static NSString *OtherCellIdentifier = @"MyOtherCellIdentifier";
        MyOtherCell *otherCell = (MyOtherCell *)[tableView dequeueReusableCellWithIdentifier: OtherCellIdentifier];

        if (otherCell == nil) {
            otherCell = [[MyOtherCell alloc] initWithStyle: UITableViewCellStyleDefault reuseIdentifier: OtherCellIdentifier];
        }

        // Do stuff with other cell

        cell = otherCell;
    }

    return cell;    
}

Most developers are still pretty comfortable with this. Locality is high and the code is fairly easy to reason about, but that cast is there again. I copied the else case and changed the bits I needed but I'm still using strings as a type system to discern the type of cell that should be dequeued. This could become a problem as my table grows in complexity.

iOS 5 added prototype cells, which sticks the reuse identifier in a storyboard. It also added - (void)registerNib:(UINib *)nibforCellReuseIdentifier:(NSString *)identifier and iOS 6 gave us - (void)registerClass:(Class)cellClass forCellReuseIdentifier:(NSString *)identifier which is often called from -(void)viewDidLoad. These methods allow us to neatly separate out the concerns of cell configuration and instantiation/registration, but now it's spread all over our class, and our stringly typed cells are likely to bite us at some point in the future.

Luckily, Swift gives us some tools to help deal with this.

Providing a consistent method of determining identifers

Identifiers are how we determine type information for cells in UITableView and UICollectionView. They work well for small applications but degrade as the complexity of the UITableView (and thus the number of types) increases. Providing a consistent method of determining identifiers will go a long way towards making life easier.

extension UITableViewCell {
    public class func defaultIdentifier() -> String {
        return NSStringFromClass(self)
    }
}

extension UITableViewHeaderFooterView {
    public class func defaultIdentifier() -> String {
        return NSStringFromClass(self)
    }
}

Now we have an easy way of producing an identifier based on the type. Additionally, subclasses can provide their own implementations if the default implementations don't suffice. This approach isn't uncommon, and you could do a lot worse than getting here and calling it a day.

Note that we use NSStringFromClass(self) instead of String(self) for the default implementation. This is because NSStringFromClass includes the module name which can be helpful for differentiating from the MyApp.MyCell and SomeThirdPartyDependecy.MyCell in the event that the two are used in the same tableview at some point in the future.

Using Types for Registration

But why would we stop there? Now that every type has a method of producing an identifier why not just use the type itself for registration?

// Cells
extension UITableView {
    public func register<T: UITableViewCell>(cellClass `class`: T.Type) {
        registerClass(`class`, forCellReuseIdentifier: `class`.defaultIdentifier())
    }

    public func register<T: UITableViewCell>(nib: UINib, forClass `class`: T.Type) {
        registerNib(nib, forCellReuseIdentifier: `class`.defaultIdentifier())
    }
}

// Header / Footer
extension UITableView {
    public func register<T: UITableViewHeaderFooterView>(headerFooterClass `class`: T.Type) {
        registerClass(`class`, forHeaderFooterViewReuseIdentifier: `class`.defaultIdentifier())
    }

    public func register<T: UITableViewHeaderFooterView>(nib: UINib, forHeaderFooterClass `class`: T.Type) {
        registerNib(nib, forHeaderFooterViewReuseIdentifier: `class`.defaultIdentifier())
    }
}

This is starting to look pretty good, we have strong types and can use the types themselves as registration rather than providing a potentially error prone String -> Type mapping. This is also pretty trivial, the nice bit here is that generics give us a bit of type safety around what we can register.

Using Types to Dequeue

We had been using the above for quite a while and it took us a bit longer for us to put together then next piece. Which uses generics and the type system to get an appropriate type back out.

extension UITableView {
    func dequeueReusableCell<T: UITableViewCell>(withClass `class`: T.Type) -> T? {
        return dequeueReusableCellWithIdentifier(`class`.defaultIdentifier()) as? T
    }

    func dequeueReusableCell<T: UITableViewCell>(withClass `class`: T.Type, forIndexPath indexPath: NSIndexPath) -> T {
        guard let cell = dequeueReusableCellWithIdentifier(`class`.defaultIdentifier(), forIndexPath: indexPath) as? T else {
            fatalError("Error: cell with identifier: \(`class`.defaultIdentifier()) for index path: \(indexPath) is not \(T.self)")
        }
        return cell
    }

    func dequeueResuableHeaderFooterView<T: UITableViewHeaderFooterView>(withClass `class`: T.Type) -> T? {
        return dequeueReusableHeaderFooterViewWithIdentifier(`class`.defaultIdentifier()) as? T
    }
}

Some of you may be looking at that fatalError() and getting cagey but I assure you it`s all right. Way back in June, Natasha wrote a post about using the right dequeue method. Let's have a look at that type signature again.

func dequeueReusableCellWithIdentifier(identifier: String, forIndexPath indexPath: NSIndexPath) -> UITableViewCell

There's no ? at the end but that string as an identifier means one of two things. Either UIKit is lying to us or somewhere deep in it's bowels it's going to have a fit and crash on you. Turns out it's the latter and you get a NSInternalConsistencyException. The docs say:

IMPORTANT

You must register a class or nib file using the registerNib:forCellReuseIdentifier: or registerClass:forCellReuseIdentifier: method before calling this method.

This makes sense, how can UITableView guarantee that there is going to be a cell unless that identifier is used to register a cell class? If the identifier isn't registered then there's been an error, possibly something as simple as a typo on the part of the programmer. The motivation for Natasha's article is to get away from having to unwrap optional cells. Unfortunately if we have custom cell subclasses we still end up with either:

if let cell = tableView.dequeueReusableCellWithIdentifier("MyCellIdentifier", forIndexPath: indexPath) as? MyCell {
    // Do stuff with cell
}

or

let cell = tableView.dequeueReusableCellWithIdentifier("MyCellIdentifier", forIndexPath: indexPath) as! MyCell
// Do stuff with cell

Neither of these are particularily nice looking: we either introduce a different if let to check that the type is the one expected or we perform a force cast to the type and hope that there isn't a mapping mis-match between cell and identifier. Both solutions will still crash if the identifier has never been registered and they litter your code with a bunch of explicit type information. Our new dequeue method looks much cleaner.

let cell = tableView.dequeueReusableCell(withClass: MyCell.self, forIndexPath: indexPath)
// Do stuff with cell

Putting it all together we get:

class MyTableViewController: UITableViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.register(cellClass: MyCell.self)
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withClass: MyCell.self, forIndexPath: indexPath)

        // Do stuff with cell

        return cell
    }
}

This looks pretty good. We no longer need to rely on strings for registration and dequeuing. We don't have to add type annotations or casting to use a custom cell. And if we get a fatal error the problem is easy to debug as it simply means that the cell you are trying to use was never registered with this class.

You can grab the complete code here.