Uncategorized

More flexible UITableViews

Originally, UITableViews were envisioned to hold homogenous data. Something like this:

how_transfer_contacts_iphone_contacts_675

Every cell was the same type, just with different data backing it. And so the UITableView API was designed with that in mind. In your controller you’d register a cell type, and in the data source you’d set the data on that cell. Easy.

Unfortunately, as iOS developers, we’re often building table views that look more like this:

9a0435_fa31d179034840e2ae84ccbf013b4e27_mv2

Now, this certainly looks nicer, but it’s much more complicated. There are at least 3 different types of cells in that table that we can see. In fact, there are 6 cells involved in making this table look nice and do what it needs to do. UITableViewDataSource was not designed with that sort of complexity in mind. The logic of what cell to set up where gets pretty complicated pretty quickly. Here’s what the cell for index path code for this screen looked like:

    UITableViewCell *cell;
    
    if ([self.viewModel numberOfEvents]) {
        if (indexPath.row == 0) {
            SearchTableViewCell *searchCell = [self.tableView dequeueReusableCellWithIdentifier:@"searchCell"];
            searchCell.searchBar.delegate = self;
            cell = searchCell;
        } else if (indexPath.row == 1) {
            EventTableViewCell *eventCell = [tableView dequeueReusableCellWithIdentifier:@"eventLargeCell" forIndexPath:indexPath];
            [self configureCell:eventCell indexPath:indexPath];
            cell = eventCell;
        } else if (indexPath.row  3) {
            EventTableViewCell *eventCell = [tableView dequeueReusableCellWithIdentifier:@"eventCell" forIndexPath:indexPath];
            [self configureCell:eventCell indexPath:[NSIndexPath indexPathForRow:indexPath.row - 1 inSection:indexPath.section]];
            cell = eventCell;
        } else if (indexPath.row == 3) {
            cell = [tableView dequeueReusableCellWithIdentifier:@"addEventCell" forIndexPath:indexPath];
        }
    } else {
        EmptyArtsFeedTableViewCell *emptyCell = [self.tableView dequeueReusableCellWithIdentifier:@"emptyCell"];
        emptyCell.delegate = self;
        cell = emptyCell;
    }

Gross huh? Nested if-else’s, adjusting the row index based on its position relative to other cells, dequeuing different xibs for the same cell class… yuck. Now imagine having to add a new cell type to this. Where would you put it? How would you insert it into this mess of logic? I shudder to think.

Potential solutions

Now, you might be tempted to say well, we just need to use different data sources. A data source for the event cells, a loading cell data source, an empty cell data source, and so on. And that would help.

Unfortunately, it would still leave the worst of the logic (everything in the numberOfEvents branch) unchanged. The real problem is the static cells that sit in the middle of our list of events (the search cell and the add event cell). They require us to make all kinds of weird adjustments to our indexing, test for different cases, and so on.

Dreaming of a better tomorrow

What do we wish this was like? Well, we might look back at the good old days when UITableViews were simple. You had an NSArray of your models and you’d just grab models[indexPath.row] and configure your cell with it. Golly but wouldn’t it be nice if we could just do something like:

var cells = [UITableViewCell]()
cells.append(searchCell)
cells.append(largeEventCell)
cells.append(contentsOf: eventCells[1...2])
cells.append(addEventCell)
cells.append(contentsOf: eventCells[3..<eventCells.count])

Because then the cell for index path method would just look like this:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return cells[indexPath.row]
}

But unfortunately, holding all those views in memory would be expensive. If only there were a way…

Hallelujah!

Let’s look at how we might do this. As a nice side effect, our solution will give us much better separation of concerns.

What we want to do is break up and simplify that ‘cell for index path’ method. We’re going to make a protocol that sits between our UITableViewDataSource and the specific cell’s implementation details:

public protocol DataSourceItemProtocol {
    func cellIdentifier() -> String
    func cellClass() -> UITableViewCell.Type
    func configureCell(_ cell: UITableViewCell)
}

The first function will provide the identifier for the cell to be used in dequeuing. The second function will return a subclass of UITableViewCell (it’s important to note that it returns the class not an instance of the class – this will be important later). The last function accepts an instance of a UITableViewCell subclass for configuring.

Now, we can use this to construct a general purpose UITableViewDataSource built around an array of these protocols:

open class MultiModelTableViewDataSource: NSObject, UITableViewDataSource {
    var items: [DataSourceItemProtocol]?

    open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items?.count ?? 0
    }

    open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if let item = items?[indexPath.row] {
            if let cell = tableView.dequeueReusableCell(withIdentifier: item.cellIdentifier()) {
                item.configureCell(cell)
                return cell
            }
        }
        return UITableViewCell()
    }
}

This class only relies on an array of the protocol defined above, so it’s super simple. To return a properly configured cell, just grab the item from the indexPath, dequeue a cell from the item’s cellIdentifier, and configure it with the item’s configureCell function. The best part? The protocol is super lightweight, so we can easily construct and store DataSourceItems that implement that protocol with little to no memory impact. For example, here’s a simple one:

class EventItem: DataSourceItemProtocol {
    func cellIdentifier() -> String { "eventCell" }
    func cellClass() -> UITableViewCell.Type { return EventCell.Type }
    func configureCell(_ cell: UITableViewCell) {
        // ... configure cell as normal here ...
    }
}

How about registering the cells? Couldn’t be easier!

for item in items {
    let className = String(describing: item.cellClass())
    if Bundle.main.path(forResource: className, ofType: "nib") != nil {
        tableView.register(UINib(nibName: className, bundle: nil), forCellReuseIdentifier: item.cellIdentifier())
    } else {
        tableView.register(item.cellClass(), forCellReuseIdentifier: item.cellIdentifier())
    }
}

This just loops through the items and links the nibs/classes with the cell identifier, depending on how you’ve created the UITableViewCells.

Putting all this together, we can just construct an array of items and pass them to the data source, like so:

var items = [DataSourceItemProtocol]()
items.append(searchItem)
items.append(largeEventItem)
items.append(contentsOf: eventItems[1...2])
items.append(addEventItem)
items.append(contentsOf: eventItems[3..<eventCells.count])
dataSource.items = items

This is significantly simpler and easier to grok. Now when you consider how to add a new type of cell, the steps are clear: create a new item implementation and append it to the items wherever is appropriate. Whew! So much better!

For a full implementation of this, checkout this GitHub repo. It’s also available as a Cocoapod, so just add pod 'FlexDataSource' to your Podfile. To see how this can be used in practice, check out Habitica’s iOS code (wherein it’s called MultiModelTableViewDataSource), where we used it here and there. It can be really helpful when using Functional Programming, since it fits in neatly as a part of the data pipeline.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s