How to type your diffable data source identifiers
In this post we describe three different data types for 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:
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.