iOS 13 — Be Dynamic with DiffableDataSource
Overview
“attempt to insert row 144 into section 0, but there are only 0 rows in section 0 after the update”.
I think there isn’t an iOS developer that hasn’t seen this crash before. Finally, after more than 10 years, Apple decided to give a decent solution to one of the most frustrating issues we have on tables and collection views — animating changes to the collection.
Apple mixed a little bit of SwiftUI Concept here
It’s not a coincidence DiffableDataSource is released alongside SwiftUI. While the old good UITableViewDataSource protocol that is based on two methods — cellForRow and numberOfRows, is dealing with only cells and indexPath’s, DiffableDataSource is attached to the data itself and can distinguish between the data items with the help of Hashable protocol.
Just like SwiftUI, DiffableDataSource has a mechanism of applying changes while calculating the differences in the data for you and transition the UI to the new state with nice animation almost automatically.
Applying changes nicely it’s just the tip of the ice
Apple is selling us the DiffableDataSource as a solution for “insertRowsAtIndexPath” crashes issues, but in fact, it changes the whole way collections work with data, starting with preparing the data items, creating the cells, reloading specific cells and up to working with external services that refresh your collection.
When you first approaching DiffableDataStructure, you need to forget how UITableDatasource / UICollectionViewDatasource works for a second, because DiffableDataStructure is an entirely different thing.
These are the main steps you need to do when using DiffableDataSource:
- Prepare data items, that conform to Hashable protocol.
- Create a data source that generates the cells for the collection
- Create a snapshot and fill it with the data items.
- Respond to changes by modifying the snapshot and apply it to the data source.
Data Items
Snapshots have to work with data items that conform to Hashable to do its magic. This may sound trivial, but it’s not always the case. Sometimes, developers create more dynamic data sources that rely on some logic instead of a list of structs or objects. For instance — “if indexPath.row == 0 { // show something // )” is a common use case. Here we need to create data items and define the data source and snapshots to rely on their type.
import Foundation
enum Section {
case today
case tomorrow
case upcoming
case someday
}
struct Item : Hashable {
var id = ""
var data = ""
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: Item, rhs: Item) -> Bool {
return lhs.id == rhs.id
}
}
Since enumeration member values and swift basic types (String, Int..) all conform to Hashable protocol, it’s not that difficult to accomplish this mission.
New Datasource
So the data source object replaces the object that conforms to UITableViewDataSource, and it’s very easy to use it.
All you need to do is:
- Declare the two types for the data source — the section type and the row type.
- Allocate the data source and in the closure just return the cell according to the row type.
- There is no step 3. That’s it :)
import UIKit
class ViewController: UIViewController {
var datasource : UITableViewDiffableDataSource<Section, Item>!
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "CustomTableViewCell", bundle: nil), forCellReuseIdentifier: "CustomTableViewCell")
self.datasource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell") as! CustomTableViewCell
cell.customLabel.text = item.data
return cell
})
}
Keep a reference to this data source because you’re gonna need it to apply snapshots.
Working with Snapshots
import UIKit
class ViewController: UIViewController {
var datasource : UITableViewDiffableDataSource<Section, Item>!
var items = [Item]()
var tommorowItems = [Item]()
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "CustomTableViewCell", bundle: nil), forCellReuseIdentifier: "CustomTableViewCell")
self.datasource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell") as! CustomTableViewCell
cell.customLabel.text = item.data
return cell
})
items.append(Item(id: "1", data: "123"))
items.append(Item(id: "2", data: "456"))
items.append(Item(id: "3", data: "789"))
tommorowItems.append(Item(id: "4", data: "123"))
tommorowItems.append(Item(id: "5", data: "456"))
tommorowItems.append(Item(id: "6", data: "789"))
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([Section.today, Section.tomorrow])
snapshot.appendItems(items, toSection: Section.today)
snapshot.appendItems(tommorowItems, toSection: Section.tomorrow)
datasource.apply(snapshot)
}
}
We created the data source, but we haven’t given yet any data to work with. So, If you want to fill the data source, a snapshot is the way to go.
Create a NSDiffableDataSourceSnapshot that works with the same types of the data source, append items to the snapshot and apply the changes in the data source.
Snapshots can do more than add items and sections. They can also move, delete and reload items in your data source.
The working method is that you can take the current snapshot displayed in the data source and modify it, or you can create a new snapshot and apply it to your data source.
Also, you don’t have to keep references to the objects you pass to your snapshot — once you passed them, your data source and snapshot objects have references to them.
Now, let’s talk about common use cases and how to implement them with snapshots.
Create initial data for your collection:
To show data, your data source object needs a snapshot. Create a snapshot, add data to it and add it to your data source.
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([Section.today, Section.tomorrow])
snapshot.appendItems(items, toSection: Section.today)
snapshot.appendItems(tommorowItems, toSection: Section.tomorrow)
datasource.apply(snapshot)
Note — snapshots require at least one section to work, so just add an enum value as a section in case your data is flat.
General, it’s best to create a function that takes both a data structure and a snapshot and fill the snapshot with items.
Add, delete or move items in the snapshot:
To make changes to the current list, you don’t have to create a new snapshot. Just retrieve the current snapshot from the data source (datasource.snapshot()), and make the relevant changes.
let snapshot = datasource.snapshot()
If you want to add an item to the snapshot, use a snapshot.appendItems() and pass the list of items and the section.
func addItem() {
let snapshot = datasource.snapshot()
snapshot.appendItems([Item(id: "7", data: "000000")], toSection: Section.tomorrow)
datasource.apply(snapshot)
}
If you want to insert an item to a specific location in the list, use snapshot.insertItems.
func addItem() {
let snapshot = datasource.snapshot()
snapshot.insertItems([Item(id: "5", data: "000000")], beforeItem: item4)
datasource.apply(snapshot)
}
To delete items, use snapshot.deleteItems() and pass the list of items to delete.
func deleteItem() {
let snapshot = datasource.snapshot()
snapshot.deleteItems([Item(id: "4", data: "0000")])
datasource.apply(snapshot)
}
I want to reload specific cells
Reloading a cell though a little bit tricky. You can pass a modified item to the snapshot, but when the closure that generates the cell will run, it will pass the old item (I don’t know if it’s a bug or but it’s by design but this is how it works). The solution is always to keep an updated data store and fetch the item by it’s ID before passing to the snapshot.
func reloadItems() {
let snapshot = datasource.snapshot()
snapshot.reloadItems([item4, item5])
datasource.apply(snapshot)
}
I want to replace the current data with new data
If it’s too complicated to identify the new changes or you just want to reload the collection with new data, just create a new snapshot, fill it with what you want and apply it to the data source. If there are items in the current snapshot that exists in the new snapshot, the datasource is smart enough to apply the changes with a nice animation.
What about responding to selecting items, layout…?
We said DiffableDataSource replaces only the UITableViewDataSource. Use can still use UITableViewDelegate alongside DiffableDataSource, and implement didSelectRow just like you did before.
Calculating changes seems like a heavy task. Can I call it from a background thread?
Yes, you can! I’m not sure it’s needed though, because it seems like Apple engineers did a great job here. All you need to remember is to make it consistent — if you call it from a background thread, just make sure all the calls to the specific data source is being called from the background thread.
I need to support iOS 12
Welcome to reality — In the following year you probably will need to support iOS 12 users, so some tips for back compatibility:
Set UITableViewDataSource to the table view only for iOS 12, otherwise, the table view will work with two data sources in iOS 13, and that’s not something you want.
Move UITableViewDataSource to a different class. It’s not mandatory, but it’s better to make your life simpler when you need to support 2 different API’s.
If animating changes in iOS 12 is too complicated for you, just use UITableView.reloadData() method, and leave a better experience for iOS 13 users. Adoption of new iOS 13 versions is very fast, and in a few months iOS 13 will be the most popular iOS version.
So, that’s the end of stability issues with UITableView’s, right?
Well, almost. As long as you take actions that make sense, you are safe. But you can still crash the app with DiffableTableView.
Some examples of how to crash it:
- Add items to non-exists section
- Delete Item and then reload it.
- Add items when you don’t have sections at all.
- Move items that are not in your snapshot.
I can think of more examples, but you get. Don’t try it on App Store :)
Summary
DiffableDataSource can surely make your life easier and give your users a better user experience. Some challenges come with it — you need to adapt your app pattern (MVVM/MVP/VIPER/MVC) and see how it integrates with the new API, make changes to your model and of course, still support iOS 12 at least for the next year.
iOS Engineer at Accenture
8 个月when I insert a newItem the scroll position automatically scrolls towards the new item. is there any way to disable it?
CTO at MSApps
5 年You are on fire you blogging machine ??????