Alternative Structures for Diffable DataSource Identifier Types

In this article will be described three different structures for implementing Sections and SectionItems of the tableView dataSource.

Efthymios Liapatis

6 minute read

Introduction

As the application scales up, there is a need for a proper data type structure for diffable dataSource identifier types. The reason is that, the most conventional way as it has been introduced in “Advances in UI Data Sources - WWDC - 2019”, by using struct for Sections and enum for Rows leads to massive switch case statement and duplicated code.

Alternative Options

  • First option - Data type of one of identifier types (for Sections or for Rows) is enum
  • Second option - Data type of one of identifier types (for Sections or for Rows) is class
  • Third option - Data type for both Sections and Rows is struct

Pros and Cons of the Options

Using enum for one of identifier types

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

Example:

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.


Using class for one of identifier types

In this option is used a struct for Sections and a parent class for Rows (named SectionItem), in order to support different row types in specific dataSoure. Each new type of Row, inherits SectionItem.

In addition, SectionItem conforms to 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()
    }
}

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 is scalable.

😟 Bad, because of complexity regarding the implementation of Equatable protocol in a class hierarchy.

😟 Bad, because leads to creation of more files.


Using Type Erasure

In the last option struct is used for both Sections and Rows. In order to support different row types in specific dataSoure, Rows must be an array of items conforming to Hashable, hence to Equatable. However, Equatable and any protocol it 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 in second option.

For those reasons, we will use type erasure. A wrapper type (named AnyHashableSectionItem) to encapsulate Equatable. Khawer Khaliq analyze this structure more in depth in this two-parts 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)
    }
}

Example:

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.

comments powered by Disqus