Better multiple-cell updates with DiffableDataSource
Handling multiple cell updates at the same time for UITableViews and UICollectionViews has been chaotic for me. Sometimes, it just breaks. We all need to handle all the possible edge cases, or it will create a crash and log out this dreaded error message:
"reason: 'attempt to insert row 3 into section 0,
but there are only 3 rows in section 0 after the update"
So in this article, I wanted to share one neat little solution that will brush the chaos away. Introducing the DiffableDataSource.
So what exactly is a DiffableDataSource?
To put it simply, DiffableDataSource allows us to manage the updates to cells in UICollectionView or UITableView in a simpler, efficient way.
The simplicity of DiffableDataSource allows us to manage complex TableView or CollectionView updates without the need of handling multiple edge cases (that might cause some bugs or crashes).
This includes the insertion/deletion of cells like what we do usually in a TableView/CollectionView, in which we will need to only update that specific cell instead of the entire data.
How can we implement this DiffableDataSource exactly?
To answer this question, I will be sharing my public Github project for you to download, and I will be making my code walkthrough for it.
When you download the project, you might be overwhelmed by the code that I have written. But do not be afraid, I will explain to you step by step how the project works.
This is the sample demo of the project that you will be working on.
You can download the project for free at: https://github.com/kevinabram111/DiffableDataSource.
Feel free to chat or connect with me on LinkedIn: https://www.linkedin.com/in/kevin-abram-719923128 if you have any questions further. I will be telling you how it works in detail below.
Part 1: Understanding the sections and cells used for this project
For this project, I will show you the sections and cells that we will use by this code that is written inside ViewController.swift, which is:
enum CellSectionType: Hashable {
case searchBar
case addedList
case list
}
enum CellListType: Hashable {
case searchBar
case addedList(name: String)
case list(name: String)
}
So in this code below, the CellSectionType will be the sections that will act to group all the different types of cells together to make the updating of cells far simpler and easier, as shown in the screenshot below:
The screenshot above illustrates the sections that we will be handling in the project. We will be using an enum as a tool to dequeue cells easier and differentiate one from another. The cases of the enum for the sections (CellSectionType) will be as follows:
- The addedList enum case will be the top section of the UITableView that shows the people that are added
- The searchBar enum case will be the middle section of the UITableView that shows the search bar
- The list enum case will be the bottom section of the UITableView that shows the list of people that can be added
We also conform those cases to the hashable protocol since it is mandatory for DiffableDataSource to differentiate between each case.
The CellListType will be the cells that are inside those sections. I’m adding names to the addedList and list case so that we can pass the names to the dataSource of the UITableView to the cells, like “Lucas John”, “Mason Alexander” etc.
Part 2: The basic structure of the ViewController file
var addedListDictionary: [String: String] = [:]
var addedList: [CellListType] = []
var list: [CellListType] = []
var searchText: String = ""
let fullList: [CellListType] = [
.list(name: "Lily Rose"),
.list(name: "Ava Grace"),
.list(name: "Ruby Mae"),
.list(name: "Stella Luna"),
.list(name: "Jack Ryan"),
.list(name: "Emma Kate"),
.list(name: "Olivia Jade"),
.list(name: "Ethan James"),
.list(name: "Amelia Rose"),
.list(name: "Lucas John"),
.list(name: "Madison Claire"),
.list(name: "Noah William"),
.list(name: "Isla Faith"),
.list(name: "Mason Alexander"),
.list(name: "Grace Elizabeth"),
.list(name: "Liam Thomas"),
.list(name: "Chloe Grace"),
.list(name: "Jackson Lee"),
.list(name: "Scarlett Belle")
]
So far, the ViewController class will contain the code above as the data that we will store, with these explanations:
- The addedListDictionary will contain the dictionary of the people that are added. We store the names of those people in dictionaries for faster querying.
- The addedList will contain the array of enums for the people that are added. We will use this during the refreshing of data for the UITableView.
- The list will contain the array of enums for the people that are not added yet and can be added. We will use this during the refreshing of data for the UITableView.
- The searchText will contain the last searched text, this will be used to remember and filter out the list based on the keyword.
- The fullList will contain the data of all the people, which will be used to compare all the data or to reset the list.
lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.delegate = self
tableView.register(ListTableViewCell.self, forCellReuseIdentifier: "ListTableViewCell")
tableView.register(SearchBarTableViewCell.self, forCellReuseIdentifier: "SearchBarTableViewCell")
tableView.backgroundColor = .clear
return tableView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
list = fullList
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
view.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(didTapScreen)))
tableView.dataSource = dataSource
refreshSnapShot()
}
So far, all of these work hand in hand together. We also implement the code above to lay out the UITableView on the view. I lay out the code programmatically in this case with NSLayoutConstraint. Note that we will need to create our own DataSource in this case and assign it to the UITableView instead of the usual way.
As an additional note, I implemented a UITapGestureRecognizer to handle the keyboard dismissal. The refreshSnapShot function, which will handle the dataSource data will be discussed below.
Part 2: The dataSource handling for the UITableView
lazy var dataSource = UITableViewDiffableDataSource<CellSectionType, CellListType>.init(
tableView: tableView,
cellProvider: { [weak self] tableView, indexPath, cellType in
guard let self = self else { return UITableViewCell() }
return self.setupDataSource(tableView, indexPath, cellType)
}
)
func refreshSnapShot() {
snapShot = NSDiffableDataSourceSnapshot<CellSectionType, CellListType>()
snapShot.appendSections([.addedList, .searchBar, .list])
snapShot.appendItems(addedList, toSection: .addedList)
snapShot.appendItems([.searchBar], toSection: .searchBar)
snapShot.appendItems(list, toSection: .list)
dataSource.apply(snapShot, animatingDifferences: false)
}
func setupDataSource(_ tableView: UITableView, _ indexPath: IndexPath, _ cellListType: CellListType) -> UITableViewCell {
switch cellListType {
case .searchBar:
let cell = tableView.dequeueReusableCell(withIdentifier: "SearchBarTableViewCell", for: indexPath) as! SearchBarTableViewCell
cell.delegate = self
return cell
case .addedList, .list:
let cell = tableView.dequeueReusableCell(withIdentifier: "ListTableViewCell", for: indexPath) as! ListTableViewCell
cell.build(listType: cellListType, delegate: self)
return cell
}
}
In the code above, I defined the dataSource as a lazy var, and inside of it, I will call a setupDataSource function that will dequeue all of the cells inside of it based on the enum. The search bar will dequeue a search bar cell, while the addedList and list will dequeue a similar cell, just with a different add/delete button.
The refreshSnapShot function here will be called when we will need to refresh all the data on the dataSource, and reset it based on the array of the objects that we store. I called this when the user searches through the search bar, and during the initial state.
Note that we will need to append and initialize the sections that we defined in the first part here through appendSections, as well as the cells inside of those sections through appendItems, then apply the new snapShot to the dataSource so that it will be applied to the UITableView.
extension ViewController: SearchBarTableViewCellDelegate {
func didSearch(text: String) {
searchText = text
if text.isEmpty {
list = fullList.filter({ cellListType in
switch cellListType {
case .searchBar, .addedList:
return false
case .list(let name):
return addedListDictionary[name] == nil
}
})
} else {
list = fullList.filter({ cellListType in
switch cellListType {
case .searchBar, .addedList:
return false
case .list(let name):
return name.lowercased().contains(text.lowercased()) && addedListDictionary[name] == nil
}
})
}
refreshSnapShot()
}
}
For the searching of the text, we implement a didSearch function, which will be based on the textDidChange on the UISearchBar delegate. On the code above, we will handle two possible states:
- If the search text is empty, we will reset it to the full list, and filter the full list based on the people that are added. This is done so that the list on the bottom will only show the people that are not added. We are using a dictionary, in this case, to check if a specific person is added to the top list, and if they are, we don’t include them in the bottom list.
- On the other hand, if the search text is not empty, we will reset it to the full list and filter it by two conditions. We will only show the list of people that contains the name and is not added yet to the bottom.
After all of those handling, we will update the dataSource by calling the refreshSnapshot function to update the dataSource of the UITableView by the modified arrays, and voila, the search feature is working as above.
extension ViewController: ListTableViewCellDelegate {
func didTapActionButton(cellListType: CellListType) {
switch cellListType {
case .searchBar:
break
case .addedList(let name):
if let index = snapShot.indexOfItem(cellListType) {
addedList.remove(at: index)
snapShot.deleteItems([cellListType])
let newPerson: CellListType = .list(name: name)
if name.lowercased().contains(searchText.lowercased()) || searchText.isEmpty {
snapShot.appendItems([newPerson], toSection: .list)
}
addedListDictionary.removeValue(forKey: name)
list.append(newPerson)
}
case .list(let name):
if let index = snapShot.indexOfItem(cellListType) {
list.remove(at: index - addedList.count - 1)
snapShot.deleteItems([cellListType])
let newPerson: CellListType = .addedList(name: name)
snapShot.appendItems([newPerson], toSection: .addedList)
addedListDictionary[name] = name
addedList.append(newPerson)
}
}
dataSource.apply(snapShot)
}
}
For the handling of the add and deletion of the list, we will be handling it through this code, which is called through the delegate from the list through the action from the button. In the code above, we handle three possible states:
- We will not be handling anything if it is a search bar, since it should be on the list or added list.
- When we tap on the delete button on the added list, we will need to remove the specific person from the added list snapshot, the dictionary, and the added array. Then, we will need to add the new person to the list of people that are not added via snapshot and array. If the search bar is currently active and searching, then we will not be including the name of the people on the data that is shown via snapshot. For example, if we delete a person named “Lucas John” and we searched for “Grace”, the person named “Lucas John” will not appear on the list.
- On the other hand, when we tap on the add button on the list, we will need to remove the specific person from the list snapshot and the list array. Then, we will need to add the person to the added snapshot, added array, and to the dictionary as well for easier querying. Also, note that we will be doing index — addedList.count — 1 since we will need to get the list index by subtracting it from the added list, and one extra index for the search bar.
After all of these implementations, and applying all of the add/deletion changes to the dataSource, it will look like this. Notice the clean animations from the person that is removed from the list and added to the added list, and vice-versa.
Part 3: The handling of the UITableViewCells
For the handling of the UITableViewCells, we will be handling two kinds of cells; one for the SearchBar and one for both the List and the AddedList.
protocol ListTableViewCellDelegate: AnyObject {
func didTapActionButton(cellListType: CellListType)
}
class ListTableViewCell: UITableViewCell {
weak var delegate: ListTableViewCellDelegate?
var cellListType: CellListType?
lazy var label: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16)
label.textColor = UIColor.black
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
lazy var button: UIButton = {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(didTapActionButton), for: .touchUpInside)
return button
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
didInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
didInit()
}
func didInit() {
selectionStyle = .none
backgroundColor = .clear
layoutViews()
}
func layoutViews() {
contentView.addSubview(label)
contentView.addSubview(button)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16),
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -32),
button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16)
])
}
func build(listType: CellListType, delegate: ListTableViewCellDelegate?) {
self.cellListType = listType
switch listType {
case .list(let name):
label.text = name
button.setImage(UIImage.init(systemName: "plus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .large)), for: .normal)
button.tintColor = UIColor.green
case .addedList(let name):
label.text = name
button.setImage(UIImage.init(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(scale: .large)), for: .normal)
button.tintColor = UIColor.red
case .searchBar:
break
}
self.delegate = delegate
}
}
// MARK: - @objc functions
extension ListTableViewCell {
@objc func didTapActionButton() {
guard let cellListType = cellListType else { return }
delegate?.didTapActionButton(cellListType: cellListType)
}
}
For the List Table View Cell, we will be handling it via an enum case with a programmatic layout using NSLayoutConstraint. For the enum case, we will be handling it with three possible cases:
- For the list, it will show a plus button with green color. This will be the case when the user can add the person from the list and move it to the added list.
- For the added list, it will show a minus button with red color. This will be the case when the user can remove the person from the added list and move it to the list.
- For the search bar, we will not be handling it at all since it should be handled in a different cell.
protocol SearchBarTableViewCellDelegate: AnyObject {
func didSearch(text: String)
}
class SearchBarTableViewCell: UITableViewCell {
weak var delegate: SearchBarTableViewCellDelegate?
lazy var searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.backgroundColor = .white
searchBar.translatesAutoresizingMaskIntoConstraints = false
searchBar.delegate = self
return searchBar
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
didInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
didInit()
}
func didInit() {
selectionStyle = .none
backgroundColor = .clear
layoutViews()
}
func layoutViews() {
contentView.addSubview(searchBar)
NSLayoutConstraint.activate([
searchBar.topAnchor.constraint(equalTo: contentView.topAnchor),
searchBar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
searchBar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
searchBar.trailingAnchor.constraint(equalTo: contentView.trailingAnchor)
])
}
}
extension SearchBarTableViewCell: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
delegate?.didSearch(text: searchText)
}
}
For the search bar, we will be handling it via the textDidChange delegate to pass its values toward the main ViewController and lay out the search bar to the cell programmatically. In this case, whenever the user types in the search bar, it will call the function that is written on the ViewController.
Part 4: Wrapping up
To wrap up the article above, a DiffableDataSource is indeed a powerful tool that can help us in updating multiple cells in a simpler but more effective way. I hope that this article helps you with your iOS development, and will give you more insight into the available tools that will help you in building high-quality iOS apps.
If you reached the bottom of the article, thank you for reading mine, and I congratulate you for wanting to learn more about the DiffableDataSource. I will be creating articles and sharing my knowledge whenever I have the time. See you!