How to type your diffable data source identifiers

In this post we describe three different data types for diffable data source identifiers.

How to type your diffable data source identifiers
A diffable data source object is a specialized type of data source that works together with your table view object. It provides the behavior you need to manage updates to your table view's data and UI in a simple, efficient way.

https://developer.apple.com/documentation/uikit/uitableviewdiffabledatasource

Introduction

Finding the best data type for diffable data source identifier types can be controversial. The conventional way - as it has been introduced in “Advances in UI Data Sources - WWDC - 2019”  - suggests using struct for Sections and enum for Rows which can lead to massive switch case statements and quite a bit of duplicated code.

Let's see the full extent of our options:

  1. Enum for either Sections or Rows
  2. Class  for either Sections or Rows
  3. Struct  for both Sections and Rows

Enum

struct Section: Hashable {
    let uuid: String
    let title: String
    let items: [SectionItem]

    public func hash(into hasher: inout Hasher) {
        hasher.combine(self.uuid)
        hasher.combine(self.title)
    }

    public static func == (lhs: Section, rhs: Section) -> Bool {
        return lhs.uuid == rhs.uuid && lhs.title == rhs.title
    }
}

enum SectionItem: Hashable {
    case header(title: String)
    case empty(height: Float)
}

How to use:

var sections: [Section] = []
var sectionItems: [SectionItem] = []
sectionItems.append(.header(title: "Header Title"))
sectionItems.append(.empty(height: 16.0))
sections.append(Section(title: "Section Title", items: sectionItems))

Verdict

  • 😄 Good, because is less complex
  • 😄 Good, because is the most common implementation around the community
  • 😟 Bad, because leads to a lot of duplicate code
  • 😟 Bad, because leads to a massive switch case statement as it scales

Class

We this option, we use a struct for Sections and a parent class for Rows (named SectionItem), in order to support different row types in a specific datasource. Each new type of Row, inherits from SectionItem.

In addition, SectionItem conforms to the CellProvider protocol. The reason we do that is because we want to couple the cell with the SectionItem, otherwise we would need to cast each type to match with its cell and pass its data if needed, and that would lead to a long conditional statement.

struct Section: Hashable {
    let uuid: String
    let title: String
    let items: [SectionItem]

    public func hash(into hasher: inout Hasher) {
        hasher.combine(self.uuid)
        hasher.combine(self.title)
    }

    public static func == (lhs: Section, rhs: Section) -> Bool {
        return lhs.uuid == rhs.uuid && lhs.title == rhs.title
    }
}

protocol CellProvider {
    func height() -> CGFloat
    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell
}
class SectionItem: Hashable, CellProvider {
    static func == (lhs: SectionItem, rhs: SectionItem) -> Bool {
        return lhs.isEqual(to: rhs)
    }

    func isEqual(to item: SectionItem) -> Bool {
        fatalError("Needs to be overriden")
    }

    func hash(into hasher: inout Hasher) {
    }

    func height() -> CGFloat {
        return .zero
    }

    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }
}

How to use:

// Example

final class HeaderSectionItem: SectionItem {
    // MARK: Private
    private let title: String

    // MARK: Initialization
    init(title: String) {
        self.title = title
    }

    // MARK: Overriden methods
    override func height() -> CGFloat {
        return UITableView.automaticDimension
    }

    override func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }

    override func isEqual(to item: SectionItem) -> Bool {
        guard let item = item as? HeaderSectionItem else { return false }
        return self.title == item.title
    }

    override func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
}

final class EmptySectionItem: SectionItem {
    // MARK: Private
    private let _height: CGFloat

    // MARK: Initialization
    init(height: CGFloat) {
        self._height = height
    }

    // MARK: Overriden methods
    override func height() -> CGFloat {
        return self._height
    }

    override func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }

    override func isEqual(to item: SectionItem) -> Bool {
        guard let item = item as? EmptySectionItem else { return false }
        return self._height == item._height
    }

    override func hash(into hasher: inout Hasher) {
        hasher.combine(_height)
    }
}

var sections: [Section] = []
var sectionItems: [SectionItem] = []
sectionItems.append(HeaderSectionItem(title: "Header Title"))
sectionItems.append(EmptySectionItem(height: 16.0))
sections.append(Section(uuid: "First Section ID", title: "First Title", items: sectionItems))

Verdict

  • 😄 Good, because it is scalable.
  • 😟 Bad, because implementing Equatable protocol in a class hierarchy adds complexity
  • 😟 Bad, because it is verbose


Struct - using type erasure

In the last option struct is used for both Sections and Rows. In order to support different row types in a specific dataSoure, Rows must be an array of items conforming to Hashable, hence to Equatable. However, Equatable and any protocol that has Self requirements, cannot be used as a type as the error indicates when trying to use it:

Protocol ‘Equatable’ can only be used as a generic constraint because it has Self or associated type requirements

In addition, structs don’t support inheritance from another kind of struct, a solution we have followed with classes.

For those reasons, we will use type erasure. A wrapper type (named AnyHashableSectionItem) to encapsulate Equatable. Khawer Khaliq analyzes this structure in more depth in this two-part blogpost.

struct Section: Hashable {
    let title: String
    let items: [AnyHashableSectionItem]
}
protocol SectionItem {
    func isEqual(to item: SectionItem) -> Bool
    func hash(into hasher: inout Hasher)
    func height() -> CGFloat
    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell
}
extension SectionItem where Self: Hashable {
    func isEqual(to item: SectionItem) -> Bool {
        guard let sectionItem = item as? Self else { return false }
        return self == sectionItem
    }
}
protocol CellProvider {
    func height() -> CGFloat
    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell
}
public struct AnyHashableSectionItem: CellProvider, SectionItem {
    private(set) var sectionItem: SectionItem

    init(_ sectionItem: SectionItem) {
        self.sectionItem = sectionItem
    }

    func height() -> CGFloat {
        return self.sectionItem.height()
    }

    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return self.sectionItem.tableViewCell(in: tableView, indexPath: indexPath)
    }
}
extension AnyHashableSectionItem: Hashable {
    public static func == (lhs: AnyHashableSectionItem, rhs: AnyHashableSectionItem) -> Bool {
        return lhs.sectionItem.isEqual(to: rhs.sectionItem)
    }

    public func hash(into hasher: inout Hasher) {
        self.sectionItem.hash(into: &hasher)
    }
}

How to use:

struct HeaderSectionItem: SectionItem {
    private(set) var title: String

    // MARK: Initialization
    init(title: String) {
        self.title = title
    }

    // MARK: Overriden methods
    func height() -> CGFloat {
        return UITableView.automaticDimension
    }

    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }

    func isEqual(to item: SectionItem) -> Bool {
        guard let sectionItem = item as? HeaderSectionItem else { return false }
        return self.title == sectionItem.title
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
}
struct EmptySectionItem: SectionItem {
    // MARK: Private
    private let _height: CGFloat

    // MARK: Initialization
    init(height: CGFloat) {
        self._height = height
    }

    // MARK: Overriden methods
    func height() -> CGFloat {
        return UITableView.automaticDimension
    }

    func tableViewCell(in tableView: UITableView, indexPath: IndexPath) -> UITableViewCell {
        return UITableViewCell()
    }

    func isEqual(to item: SectionItem) -> Bool {
        guard let item = item as? EmptySectionItem else { return false }
        return self._height == item._height
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(_height)
    }
}
var sections: [Section] = []
var sectionItems: [SectionItem] = []
sectionItems.append(HeaderSectionItem(title: "Header Title"))
sectionItems.append(EmptySectionItem(height: 16.0))
sections.append(Section(title: "First Title", items: sectionItems.map { AnyHashableSectionItem($0) }))

Verdict

  • 😄 Good, because is scalable.
  • 😟 Bad, because of the complexity creates the type erasure structure.
  • 😟 Bad, because leads to creation of more files.