Typed, yet Flexible Table View Controller

UITableView is a bread and butter of (almost) all iOS developers. In most cases we simply present a single data type using the same UITableViewCell class and reuse identifier for all items. Guys from objc.io got us covered here. Situation gets more complicated when we want to have two, or more, different types of cells in one table view. Heterogeneity of items makes things hard to implement in context of the type system.

This article shows three approaches to this problem. Each one tries to fix issues found in its predecessor. First approach reminds something that can be seen in many Objective-C codebases. Second one leverages enumerations which turn out to not be the best fit for this problem. Third, and final, implementation is built on protocols and generics. It's a good citizen in the world of Swift.

Basics

I'll work you through an example (it's on GitHub) of creating a table view with two types of cells: a) with a text, and b) with an image, as shown on the screenshot below:

table view with 2 types of cells

UITableView displaying two types of data (text and image)

I'm fond of creating value types to encapsulate all the information a view needs to be able to configure itself. Let's call them view data. In our case they're really simple:

struct TextCellViewData {
    let title: String
}

struct ImageCellViewData {
    let image: UIImage
}

(In a real-world project™ they'll surely have more properties; image property would be of NSURL type to remove dependency on UIKit.) We'll also have two cells that can be updated with those view data structs:

class TextTableViewCell: UITableViewCell {
    func updateWithViewData(viewData: TextCellViewData) {
        textLabel?.text = viewData.title
    }
}

class ImageTableViewCell: UITableViewCell {
    func updateWithViewData(viewData: ImageCellViewData) {
        imageView?.image = viewData.image
    }
}

With this in place we're ready to start working on our view controller.

1st Approach: “Easy”

I like to not overcomplicate things in the beginning by starting with an easy implementation that puts something on the screen.

We want our table view to be driven by the data in an array (property called items). Since we use structs that don't have anything in common, it has to be of type [Any]. We use the standard table view's reuse mechanism by registering cells beforehand in registerCells(). In tableView(_:cellForRowAtIndexPath:) we check the type of a view data at a given index path and dequeue and setup a cell accordingly. Full implementation of our view controller is quite concise1:

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    var items: [Any] = [
        TextCellViewData(title: "Foo"),
        ImageCellViewData(image: UIImage(named: "Apple")!),
        ImageCellViewData(image: UIImage(named: "Google")!),
        TextCellViewData(title: "Bar"),
    ]

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        registerCells()
    }

    func registerCells() {
        tableView.registerClass(TextTableViewCell.self, forCellReuseIdentifier: textCellIdentifier)
        tableView.registerClass(ImageTableViewCell.self, forCellReuseIdentifier: imageCellIdentifier)
    }
}

extension ViewController: UITableViewDataSource {

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

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let viewData = items[indexPath.row]

        if (viewData is TextCellViewData) {
            let cell = tableView.dequeueReusableCellWithIdentifier(textCellIdentifier) as! TextTableViewCell
            cell.updateWithViewData(viewData as! TextCellViewData)
            return cell
        } else if (viewData is ImageCellViewData) {
            let cell = tableView.dequeueReusableCellWithIdentifier(imageCellIdentifier) as! ImageTableViewCell
            cell.updateWithViewData(viewData as! ImageCellViewData)
            return cell
        }

        fatalError()
    }
}

This implementation of course works, but I can think of at least a few reasons why it makes me 😞:

  1. We can't make this view controller reusable. If we decide to add a new type of cell, e.g. for video, we'll have to change the code in three places: 1) introduce a new reuse identifier, 2) registerCells(), and 3) tableView(_:cellForRowAtIndexPath:).
  2. If we decide to change items to be var and someone provides us with a view data type that we don't handle, we'll hit fatalError() in tableView(_:cellForRowAtIndexPath:).
  3. We know that there's some relationship between a view data and a cell but it's not present anywhere in the type system.

2nd Approach: Enumeration

We can tackle some of these issues by introducing TableViewItem enumeration covering all acceptable types of view data:

enum TableViewItem {
    case Text(viewData: TextCellViewData)
    case Image(viewData: ImageCellViewData)
}

Our updated items property is now of type [TableViewItem]:

var items: [TableViewItem] = [
    .Text(viewData: TextCellViewData(title: "Foo")),
    .Image(viewData: ImageCellViewData(image: UIImage(named: "Apple")!)),
    .Image(viewData: ImageCellViewData(image: UIImage(named: "Google")!)),
    .Text(viewData: TextCellViewData(title: "Bar")),
]

We also have to update registerCells() accordingly:

func registerCells() {
    for item in items {
        let cellClass: AnyClass
        let identifier: String

        switch(item) {
        case .Text(viewData: _):
            cellClass = TextTableViewCell.self
            identifier = textCellIdentifier
        case .Image(viewData: _):
            cellClass = ImageTableViewCell.self
            identifier = imageCellIdentifier
        }

        tableView.registerClass(cellClass, forCellReuseIdentifier: identifier)
    }
}

And finally, tableView(_:cellForRowAtIndexPath:):

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let item = items[indexPath.row]

    switch(item) {
    case let .Text(viewData: viewData):
        let cell = tableView.dequeueReusableCellWithIdentifier(textCellIdentifier) as! TextTableViewCell
        cell.updateWithViewData(viewData)
        return cell
    case let .Image(viewData: viewData):
        let cell = tableView.dequeueReusableCellWithIdentifier(imageCellIdentifier) as! ImageTableViewCell
        cell.updateWithViewData(viewData)
        return cell
    }
}

One can argue that there are some advantages to this approach:

  1. A client of our view controller can provide only view data types that we accept.
  2. We replaced those pesky if statements with exhaustive switches which allowed us to get rid of fatalError().

We could improve this implementation further, for example by simplifying dequeing and updating to look like this:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let item = items[indexPath.row]

    switch(item) {
    case let .Text(viewData: viewData):
        return tableView.dequeueCellWithViewData(viewData) as TextTableViewCell
    case let .Image(viewData: viewData):
        return tableView.dequeueCellWithViewData(viewData) as ImageTableViewCell
    }
}

The sad thing is, though, that we have to keep writing those switch statements all over the place. At the moment we've got only two of them, but it's not hard to imagine a need for more. If, for example, Auto Layout became a performance bottleneck and we moved to using manual layout, we'd have to use another switch in tableView(_:heightForRowAtIndexPath:).

I certainly could live with this implementation but I couldn't get those switch statements out of my head, which kept me going.

3rd (Final) Approach: Protocols and Generics

Let's forget everything we did in 1st and 2nd approaches and start again from scratch.

Updatable

We operate on cells that can be updated with some view data, so let's introduce Updatable protocol with an associated type called ViewData:

protocol Updatable: class {
    typealias ViewData

    func updateWithViewData(viewData: ViewData)
}

and make our cells conform to it:

extension TextTableViewCell: Updatable {
    typealias ViewData = TextCellViewData
}

extension ImageTableViewCell: Updatable {
    typealias ViewData = ImageCellViewData
}

After working through two previous approaches we're able to notice that for each view data instance kept in items array we need to:

  • know a cell class that should be used for it
  • know a reuse identifier that should be used for it
  • be able to update a cell with it

CellConfigurator

So, with this in mind, let's wrap a view data struct in an another struct that will provide this additional information and functionality. Let's call it CellConfigurator:

struct CellConfigurator<Cell where Cell: Updatable, Cell: UITableViewCell> {

    let viewData: Cell.ViewData
    let reuseIdentifier: String = NSStringFromClass(Cell)
    let cellClass: AnyClass = Cell.self

    ...

It's a generic struct with Cell type parameter. This type parameter is constrained: it has to conform to Updatable and be a subclass of UITableViewCell.

CellConfigurator has three properties: viewData, reuseIdentifier and cellClass. viewData's type depends on the type of Cell and it's an only property without a default value. Values of two other properties depend on the concrete type of Cell parameter (which as a newcomer to Swift I find really cool!).

    ...
    // further part of CellConfigurator

    func updateCell(cell: UITableViewCell) {
        if let cell = cell as? Cell {
            cell.updateWithViewData(viewData)
        }
    }
}

Finally, we've got updateCell() method that takes UITableViewCell instance and updates it with viewData. We don't use Cell type here because UITableViewCell object is what dequeueReusableCellWithIdentifier(_:forIndexPath:) returns. Whew, that was a long description for a relatively short implementation.

Then, we just go and use CellConfigurator instances in items array:

let items = [
    CellConfigurator<TextTableViewCell>(viewData: TextCellViewData(title: "Foo")),
    CellConfigurator<ImageTableViewCell>(viewData: ImageCellViewData(image: UIImage(named: "Apple")!)),
    CellConfigurator<ImageTableViewCell>(viewData: ImageCellViewData(image: UIImage(named: "Google")!)),
    CellConfigurator<TextTableViewCell>(viewData: TextCellViewData(title: "Bar")),
]

But wait, what? We ended up with a compile-time error:

Type of expression is ambiguous without more context

That's because CellConfigurator is generic, however Swift's Array is homogeneous. So we can't simply put CellConfigurator<TextTableViewCell> and CellConfigurator<ImageTableViewCell> instances in one array. This could stop us for good, but it won't.

After some time there comes the aha! moment 💡. Cell type parameter is used only in the declaration of viewData. So, we can hide the exact Cell type used in CellConfigurator from a client of the API by adding a non-generic protocol:

protocol CellConfiguratorType {
    var reuseIdentifier: String { get }
    var cellClass: AnyClass { get }

    func updateCell(cell: UITableViewCell)
}

and declaring that CellConfigurator conforms to it:

extension CellConfigurator: CellConfiguratorType {
}

If we now change items type to:

let items: [CellConfiguratorType]

the code will compile!

View Controller

We can now update other parts of our view controller. registerCells() becomes so simple:

func registerCells() {
    for cellConfigurator in items {
        tableView.registerClass(cellConfigurator.cellClass, forCellReuseIdentifier: cellConfigurator.reuseIdentifier)
    }
}

tableView(_:cellForRowAtIndexPath:) also gets a lot simpler, which is for sure a good sign:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cellConfigurator = items[indexPath.row]
    let cell = tableView.dequeueReusableCellWithIdentifier(cellConfigurator.reuseIdentifier, forIndexPath: indexPath)
    cellConfigurator.updateCell(cell)
    return cell
}

There are still some steps left to do to make our view controller reusable, like allowing items to be changed from the outside. We won't go through them here, but you can see the final implementation divided into a framework and an example app on GitHub: ConfigurableTableViewController.

Conclusion

Let's see how our final implementation stands against the pain points from first two approaches:

  1. We don't have to touch the view controller at all if we want to add a new type of cell.
  2. The view controller is type safe. We'll get a compile-time error if we provide a view data and a cell that don't cooperate with each other.
  3. We don't have any switch statements that need to be constantly updated.

So, it looks like the 3rd approach solved all our issues 🎉. I think that we learned, once again, that it's worth pushing forward. A better solution is often just around the corner.

Thanks to Maciej Konieczny and Kamil Kołodziejczyk for reading drafts of this.


  1. ViewController is a data source of the table view to keep an example concise. In a real project you may want to extract the data source into a separate object.