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) { }
}
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
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
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
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
]
)
}
}
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)
}
}
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.
This is actually one of the approaches we used to design Bento 🍱
Written by Serg Dort, who works and lives in London builds useful things. You can follow him on Twitter