iOS Developer in search for meaning 🧘‍♂️

Component-Based UITableView

August 08, 2018

UITableView and UICollectionView are workhorses of iOS development, but they demand significant boilerplate. That boilerplate often ends up in view controllers, bloating them unnecessarily.

A common solution is extracting UITableViewDataSource into a dedicated class:

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 in
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.setup(with: item)
        return cell
    }
}

This is cleaner—the data source is testable and reusable. Just provide a cellFactory closure to dequeue and configure cells.

But what happens when you need multiple cell types?

The problem: heterogeneous cells

Say you have three data types, each requiring a different cell:

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) { }
}

Approach 1: Using Any

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

    private lazy var dataSource = ArrayTableViewDataSource<Any>(self.tableView) { tableView, indexPath, item 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: "Cell2", for: indexPath) as! CellType2
            cell.setup(with: item)
            return cell
        }

        if let item = item as? Type3 {
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell3", for: indexPath) as! CellType3
            cell.setup(with: item)
            return cell
        }

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

Problems:

  • The cellFactory grows linearly with cell types
  • Relies on duck typing for dispatch
  • No compile-time guarantee that all types are handled

Approach 2: 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 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: "Cell2", for: indexPath) as! CellType2
            cell.setup(with: item)
            return cell
        case let .item3(item):
            let cell = tableView.dequeueReusableCell(withIdentifier: "Cell3", for: indexPath) as! CellType3
            cell.setup(with: item)
            return cell
        }
    }
}

Better—we get exhaustiveness checking. But:

  • The cellFactory is still bloated
  • Every screen with different cell combinations needs its own enum
  • Violates the Open-Closed Principle: adding a cell type means modifying every usage site

Both approaches also force you to expose cell internals publicly just to populate them.

A better approach: self-rendering components

What if each item knew how to render itself and which cell type it belongs to?

A component needs:

  • A reuse identifier
  • The ability to register its cell type
  • Access to the cell instance for rendering
protocol Component {
    associatedtype Cell: UITableViewCell

    var reuseID: String { get }

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

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

We can provide default register(in:) implementations for programmatic and nib-based cells:

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)
    }
}

The PAT problem

Component has an associated type, so we can’t create [Component].

error

The solution: type erasure.

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 mismatch")
                return
            }
            base.render(in: cell)
        }
    }

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

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

extension Component {
    var anyComponent: AnyComponent {
        return AnyComponent(self)
    }
}

Now the data source simplifies—no more cellFactory:

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
    }
}

Usage:

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

    private func showData() {
        dataSource.update(with: [
            Title(title: "Title").anyComponent,
            TitleDetails(title: "Title", details: "Details").anyComponent
        ])
    }
}

Cleaner syntax with custom operators

The .anyComponent calls are noisy. A custom operator can help:

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])
}

Now:

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

  • ArrayTableViewDataSource becomes self-sufficient—no external configuration needed
  • Each Component encapsulates its own rendering logic
  • Adding new cell types means adding new components—no existing code changes required (Open-Closed Principle)
  • Components enable consistent, reusable UI building blocks across your app

P.S.

This pattern evolved into Bento, which we built at Babylon Health.