iOS Developer in search for meaning 🧘‍♂️

Component Based UITableView

August 08, 2018

UITableView and UICollectionView are one of the most commonly used classes to display data. But they require to write a lot of boilerplate code in order to show it on screen. And it quite often end up being in a ViewController, making it larger then it should be.

In order to prevent this, a common practice is to abstract UITableViewDataSource into a separate class. The simplest use case could look something like this:

public final class ArrayTableViewDataSource<T>: NSObject, UITableViewDataSource {
    private weak var tableView: UITableView?
    private let cellFactory: (UITableView, IndexPath, T) -> UITableViewCell
    private var items: [T] = []

    public init(_ tableView: UITableView, cellFactory: @escaping (UITableView, IndexPath, T) -> UITableViewCell) {
        self.tableView = tableView
        self.cellFactory = cellFactory
    }

    public func update(with items: [T]) {
        self.items = items
        self.tableView?.reloadData()
    }

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

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

class MyViewController : UIViewController {
    private lazy var dataSource = ArrayTableViewDataSource<Item>(self.tableView) { (tableView, indexPath, item) -> UITableViewCell in
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

        cell.setup(with: item)

        return cell
    }
}

It does looks nice, we abstracted UITableViewDataSource boilerplate into a separate class that can be easily unit tested and reused across multiple places of our apps, what we need to do is just to provide a cellFactory closure to dequeue a cell and setup it with a view model or a DTO.

For a simple use case it works fine but what if we want to display items of different types in a different types of cells?

Let’s say we have 3 different types of data that we want to display in a table view. A common solution for this problem would be to use Any type or to use enums with associated values.

struct Type1 {
    let title: String
}

final class CellType1: UITableViewCell {
    func setup(with: Type1) { }
}

struct Type2 {
    let title: String
    let detail: String
}

final class CellType2: UITableViewCell {
    func setup(with: Type2) { }
}

struct Type3 {
    let title: String
    let detail: String
    let subtitle: String
}

final class CellType3: UITableViewCell {
    func setup(with: Type3) { }
}

Using Any type:

 class MyViewController : UIViewController {
    private let tableView = UITableView()

    private lazy var dataSource = ArrayTableViewDataSource<Any>(self.tableView) { (tableView, indexPath, item) -> UITableViewCell in

        if let item = item as? Type1 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType1

            cell.setup(with: item)

            return cell
        }

        if let item = item as? Type2 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType2

            cell.setup(with: item)

            return cell
        }

        if let item = item as? Type3 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType3

            cell.setup(with: item)

            return cell
        }

        assertionFailure("Did not handle item of type", type(of: item))
        return UITableViewCell()
    }
}

The down sides of this approach are:

  • implementation of cellFactory grows very fast, and we only have 3 different types of cells.

  • We are using Duck typing to determine which type of cell to display

  • There are no compile time guaranties that we handled all type of items

Using enum with associated values

enum CellItem {
    case item1(Type1)
    case item2(Type2)
    case item3(Type3)
}

class MyViewController : UIViewController {
    private let tableView = UITableView()

    private lazy var dataSource = ArrayTableViewDataSource<CellItem>(self.tableView) { (tableView, indexPath, cellItem) -> UITableViewCell in
        switch cellItem {
        case let .item1(item):
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType1

            cell.setup(with: item)

            return cell
        case let .item2(item):
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType2

            cell.setup(with: item)

            return cell
        case let .item3(item):
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell1", for: indexPath) as! CellType3

            cell.setup(with: item)

            return cell
        }
    }
}

Pros:

  • We have a compile time guaranties that we will handle all types of items

  • We do not use Duck typing

Cons:

  • Implementation of the cellFactory is still quite big.

  • For each screen where we need list with a different type of cells we would need to create a new CellItem type.

  • We violate Open Closed Principle , if we add a new type of cell we would need to modify code in every places where we use it.

Also one of the things that I do not like about both of these approaches is that we need to break encapsulation (make all of the properties public) in order to put data to a cell

Can we do better?

Yes we can! What if we treat each different type of item as a Component that knows how to render itself and in which type of UITableViewCell it can be rendered?

Let’s think what do we need for it to be rendered in a table view:

  • We need reuseID

  • We need ability to register Cell type or Nib in the UITableView

  • And we need to have an access to UITableViewCell object to populate its views

So minimal interface could look something like this

protocol Component {
    associatedtype Cell: UITableViewCell

    var reuseID: String { get }

    func register(in tableView: UITableView)
    func render(in cell: Cell)
}

extension Component {
  // default implementation
    var reuseID: String {
        return String(reflecting: Cell.self)
    }
}

We can go even further and add default implementation for register(in:) method for NibLoadable cells and cells created manually:

protocol NibLoadable {
    static var nib: UINib { get }
}

extension Component {
    func register(in tableView: UITableView) {
        tableView.register(Cell.self, forCellReuseIdentifier: reuseID)
    }
}

extension Component where Cell: NibLoadable {
    func register(in tableView: UITableView) {
        tableView.register(Cell.nib, forCellReuseIdentifier: reuseID)
    }
}

But doing so we’ll have a serious problem, a Component is a protocol with associated type, which means that we can not have an array of components

error

For that we need to “erase” a type:

final class AnyComponent {
    private let _register: (UITableView) -> Void
    private let _render: (UITableViewCell) -> Void
    let reuseID: String

    init<Base: Component>(_ base: Base) {
        self.reuseID = base.reuseID
        self._register = base.register
        self._render = { cell in
            guard let cell = cell as? Base.Cell else {
                assertionFailure("Type missmatch")
                return
            }
            base.render(in: cell)
        }
    }

    func register(in tableView: UITableView) {
        _register(tableView)
    }

    func render(in cell: UITableViewCell) {
        _render(cell)
    }
}

extension Component {
  // To be able to erase the type of component
    var anyComponent: AnyComponent {
        return AnyComponent(self)
    }
}

Now let’s improve our data source class with AnyComponent since we no longer need a cellFactory, because our components can now register and render themselves.

final class ArrayTableViewDataSource: NSObject, UITableViewDataSource {
    private var items: [AnyComponent] = []
    private weak var tableView: UITableView?

    init(items: [AnyComponent] = [], tableView: UITableView) {
        self.items = items
        self.tableView = tableView
    }

    func update(with items: [AnyComponent]) {
        self.items = items
        self.tableView?.reloadData()
    }

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

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let item = items[indexPath.row]
        guard let cell = tableView.dequeueReusableCell(withIdentifier: item.reuseID) else {
            item.register(in: tableView)

            return self.tableView(tableView, cellForRowAt: indexPath)
        }

        item.render(in: cell)

        return cell
    }
}

And using it like this:

class ViewController: UIViewController {

    private lazy var dataSource = ArrayTableViewDataSource<AnyComponent>(tableView: tableView)

    private func showData() {
        dataSource.update(with: [
            Title(title: "Tittle")
                .anyComponent, // This is not cool bro 😅
            TitleDetails(title: "Tittle", details: "Details")
                .anyComponent
            ]
        )
    }
}

Custom operators

As you may notice transforming concrete type of the Component to AnyComponent is quite annoying but we can improve it by using custom operators

precedencegroup ComponentConcatenationPrecedence {
    associativity: left
    higherThan: AdditionPrecedence
}

infix operator |-+: ComponentConcatenationPrecedence

public struct Form {
    let components: [AnyComponent]

    static var empty: Form {
        return Form(components: [])
    }
}

public func |-+ <C: Component>(form: Form, component: C) -> Form {
    return Form(components: form.components + [component.anyComponent])
}

And using it like this

class ViewController: UIViewController {
    private let tableView = UITableView()
    private lazy var dataSource = ArrayTableViewDataSource(tableView: tableView)

    private func showData() {
        let form = [
            "Hello World!",
            "Olá Mundo!",
            "Bonjour le monde",
            "Hola Mundo",
            "Hallo Welt"
        ]
        .reduce(Form.empty) { $0 |-+ Title(title: $1) }

        dataSource.render(form)
    }
}

Conclusion

  • Our ArrayTableViewDataSource is self sufficient class e.g we do not need cellFactory anymore.

  • Component implementation ensures that we render cell correctly

  • We do not break Open Closed principle, in order to add a new type of cell we just need to implement new Component

  • We can design our interfaces based on components, styling them differently. Which will enable a lot of code reuse.

P.S.

This is actually one of the approaches we used to design Bento 🍱


Serg Dort

Written by Serg Dort, who works and lives in London builds useful things. You can follow him on Twitter