- To discuss the new declarative way of handling data sources in iOS.
- Know how to configure a
UITableViewDiffableDataSourcetype. - Understand
NSDiffableDataSourceSnapshotand how to appy it to the data source. - Subclass
UITableViewDiffableDataSource<SectionIdentifier, ItemIdentifier>. - Identify the benefits of using diffable data source for table view and collection views going forward.
In iOS 13 at WWDC 2019, Apple, introduced a new approach to handling data sources when it comes to setting up table views and collection views. This new approach aims to solve a varied amount of potential bugs in iOS development. Some of the reasons behind those potential bugs is due to the fact there are various state changes in which our data and UI can be and different sources of truth. With the introduction of diffable data sources there is one source of truth. This source of truth we will see throughout this lesson is a universal snapshot associated with the table view or collection view. Once you adopt diffable data sources no longer will you encounter the following errors below, where your app crashes at runtime due to NSInternalInconsistencyException:
2020年07月11日 22:01:50.650853-0400 UIDataSources[7989:2888089] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid update: invalid number of rows in section 0. The number of rows contained in an existing section after the update (15) must be equal to the number of rows contained in that section before the update (15), plus or minus the number of rows inserted or deleted from that section (0 inserted, 1 deleted) and plus or minus the number of rows moved into or out of that section (0 moved in, 0 moved out).
This potential error has been eradicated with diffable data sources because there is one source of truth. The data source always has the most current state of the data via the snapshot.
With the introduction of SwiftUI, collection view compostional layout and diffable data sources in iOS 13, Apple is clearly moving away from the imperative approach to programming and using declarative and a compositional approach. In declarative programming you describe the structure of the program rather than describing its control flow.
Traditionally for our data source in a table view and collection views we implemented cellForRow(at:) and numberOfRows(at:) in order to setup the data for the table view or collection view. As we get new data for example from a web service API we needed to have a property observer on the main collection (array or dictionary) for the data soruce and then have reloadData called to update the table view's items. In this tradional approach as stated earlier, this lead to different sources of truth for the data soruce and more maintaining our UI in various places which leads to bugs.
Both table views and collection views support diffable data source. We use their respective APIs of UITableViewDiffableDataSource or UICollectionViewDiffableDataSource to create and configure a data soruce instance. After modifying the snapshot, we use apply() to commit the changes which updates the table view UI. With diffable data source we now have only one source of truth which is the snapshot. We can then query this snapshot for any sort of modification needed or query we have about the data.
private var tableView: UITableView! tableView = UITableView(frame: view.bounds, style: .plain) tableView.autoresizingMask = [.flexibleWidth, .flexibleHeight] tableView.backgroundColor = .systemBackground tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") view.addSubview(tableView)
Requirements as with the traditional way of configuring a table view is to register a cell
UITableViewDiffableDataSource is a generic class that has two types. Both types need to conform to the Hashable protocol:
- SectionIdentifierType: representes the sections of the table view or collection view.
- ItemIdentifierType: represents the items of a particular section.
It's good practice to use an enum which is by default Hashable and have your sections as cases of the enum type.
enum Section { case main }
This will be the type of the items in the table view cells or collection view cells. Again here the ItemIdentifierType of the UITableViewDiffableDataSource needs to conform to Hashable. We also use the hash function to define which property of the Item type should be used for hashing the type's uniqueness. In the case below we use the identifier property.
struct Item: Hashable { let name: String let price: Double let identifier = UUID() func hash(into hasher: inout Hasher) { hasher.combine(identifier) } }
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
In the declaration above both types are required to conform to the Hashable protocol as this maintains uniqueness of the section values and item values of the sections.
If you need to to use other data source methods from
UITableViewDataSourcesuch astitleForHeaderInSection()orcommit editingStyle:then you have to subclassUITableViewDiffableDataSourceand match theSectionIdentifierTypeand theItemIdentifierTypeas you defined for the data source.
A subclass stub of UITableViewDiffableDataSource
class DataSource: UITableViewDiffableDataSource<Section, Item> { // protocol methods override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { // code here } }
Using the DataSource subclass we can now update our declaration for the dataSource instance to use this subclass.
private var dataSource: DataSource!
// 1 dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) cell.textLabel?.text = item.name return cell }) // 2 dataSource.defaultRowAnimation = .fade
-
cellProvideris a closure that has 3 arguments: a pointer to the tableView, indexPath of the current item and the item itself. This closure returns an optionalUITableViewCell. The closure body is the place for cell configurations like done traditionally in thecellForRow(at:)method. In the cell provider we do not use the indexPath to find the item in question as we have a pointer to the item as one of the three arguments. -
Add the default row animation to the data source. The default animation is
.automatic. Some other options.fade.top.bottom
As stated throughout this lesson the snapshot is the source of truth for our table view's data so let's go ahead and configure it. The basic steps for setting up a snapshot is as follows:
- Declare an instance of
NSDiffableDataSourceSnapshotwhich needs to match the section and item type you specified for the data source earlier. - Append the required sections to the snapshot.
- Append the items for the section or each section if the table view or collection view has multiple sections.
- Apply the snapshot to the data source. This step is the required step to update the current snapshot which will render items to the table view or collection view.
// 1 var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() // 2 snapshot.appendSections([.main]) // 3 snapshot.appendItems(items) // 4 dataSource.apply(snapshot, animatingDifferences: false)
let updatedSnapshot = dataSource.snapshot()
updatesSnapshot.sectionIdentifiers.forEach { // code here }
- UITableViewDiffableDataSource
- UICollectionViewDiffableDataSource
- NSDiffableDataSourceSnapshot
- SectionIdentifierType
- ItemIdentifierType
snapshot()apply(_, animatingDifferences:)itemIdentifier(for:)- uses the index path to retrieve the current item- CellProvider - clousure argument on the data source initializer which has 3 arguments: a pointer to table view or collection view, the current index path and the current item
appendSections(_:)- add sections to the snapshotappendItems(_:)- add items to the current sectionappendItems(_:, toSection:_)- append items to a given sectionsectionIdentifiers- get back all the sectionsdeleteItems(_:)- remove items from the snapshotindexOfItem- return the index of an itemsectionIdentifier(containingItem: _)- get the section for a given iteminsertItems(_:, afterItem: _)- insert a given source item(s) after a destination iteminsertItems(_:, beforeItem: _)- insert a given source item(s) before a destination item
Our countdown app will start from 10 and decrement the initial value by 1 every second and add the new value as a row in the table view. Throughout the app we will make use of the snapshot as we update the table view and apply the changes.
- Create an Xcode project named Countdown.
- Navigate to the ViewController.swift file and add the following:
enum Section { case main } private var tableView: UITableView! private var dataSource: UITableViewDiffableDataSource<Section, Int>! private var timer: Timer! private var startInterval = 10 // seconds
- Configure the table view.
- Configure the data source.
- Configure the timer.
- Add the
startCountdownmethod. - Add the
decrementCountermethod. - Add the
shipmethod. - Add a
refreshbar button item to restart countdown.
In the Shopping app the user will be able view multiple sections of items and their categories. The user will be able to add a new item to a given section. The user will be able to remove an item for the shopping list. The user will also be able to reorder items.
-
Create an Xcode project named ShoppingList.
-
Define an
enumcalled Category that will comprise of a series of item categories e.g running, technology, health. -
Create the main model for the ShoppingList app and name it Item. Item will have the following properties:
- name
- price
- category
- identifier
Also Item will have to conform to the
Hashableprotocol in order to be anItemIdentifierTypeon theUITableViewDiffableDataSource. We can also define which property to be made the hashable value using thefunc hash(into hasher:)method.
-
Subclass
UITableViewDiffableDataSourceso we are able to implement the necessaryUITableViewDataSourcemethods we will need for the ShoppingList app. Some of the protocol methods we will use includefunc tableView(_ tableView: UITableView, titleForHeaderInSection section: Int). -
Configure the table view to take up the entire view's bounds. This can be done programmactically or via storyboard.
-
Configure the data source using diffable data source.
-
Setup the initial snapshot and iterate through the Category cases to create the sections. Use
filterto get all the relevant items for a given section as you iterate through the categories. The initial items for the sections will be fetched from afunc testData() -> [Item]static method. -
Implement
func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int)to get the title for each of the sections. -
Complete app at the end of Part 1 should look like the following:
Challenge for the reader:
// Challenge: // TODO: // 1. create a AddItemViewController.swift file // 2. add a View Controller object in Storyboard // 3. add 2 textfields, one for the item name and other for price // 4. add a picker view to manage the categories // 5. user is able to add a new item to a given category and click on a submit button // 6. use any communication paradigm to get data from the AddItemViewController back to the ViewController // types: (delegation, KVO, notification center, unwind segue, callback, combine)
- After the above challenge is done the new item will be available to the ItemFeedController.
- If delegation was used the item will be available in the protocol method that the ItemFeedController needs to conform to.
- Get the current snapshot.
- Append the new item to the snapshot using
appendsItems(_, toSection: )method. - Apply the snapshot.
Head to the DataSource class and implement the func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) and func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)
- Return to in the body of
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath). - In the
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath)method perform the following:- Get the current snapshot.
- Get the item using the
itemIdentifier(for: )method of the data source. - Delete the items from the snapshot.
- Apply the snapshot.
Reordering rows has a varied numbered of steps and is quite complex. There are four main scenarios at pictured below in order to achieve reordering.
Scenarios:
- Moving to the same index path.
- Moving the source item after the destination item.
- Moving the source item before the destination item.
- Moving the item to an index path that does yet exist.
Head to the DataSource class and implement the func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) method and follow the steps below.
- Use a guard statement to get the source item using the source index path and
itemIdentifier(for: )method. - Scenario 1: Use a guard to check to make sure the item is not being moved to the same index path.
- Use
itemIdentifier(for: )to get the destination item that will be replaced at the given destination index path. - Get the current snapshot.
- Scenario 2 and 3 Moving to an index path that exist. Here you want to make sure the destination item is not nil.
- Use optional binding to get both the source index and the destination index.
- Determine whether the source item should be inserted before or after the destination item. The is done by the resulting boolean value of determining if the destination index is greater than the source index and the sections are the same.
- Remove the source item from the snapshot before inserting the item at its new position.
- Scenario 2: Moving the source item after the destination item.
- Scenario 3: Moving the source item before the destination item.
- Scenario 4: Moving the item to an index path that does not yet exist.
- Get the destination section identifier using
sectionIdentifierson the snapshot and accessing the destination index path's section. - Remove the source item from the snapshot before inserting the item at its new position.
- Append the item at its new section destination.
- Get the destination section identifier using
- Apply the snapshot.
As of this writing as per animatingDifference make sure to set it to false. Attempting to set it to true and animate as reordering happens will lead to an internal consistency crash.
Full reording solution
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { // 1 guard let sourceIdentifier = itemIdentifier(for: sourceIndexPath) else { return } // 2 guard sourceIndexPath != destinationIndexPath else { return } // 3 let destinationIdentifier = itemIdentifier(for: destinationIndexPath) // 4 var snapshot = self.snapshot() // 5 if let destinationIdentifier = destinationIdentifier { // i if let sourceIndex = snapshot.indexOfItem(sourceIdentifier), let destinationIndex = snapshot.indexOfItem(destinationIdentifier) { // ii let isAfter = destinationIndex > sourceIndex && snapshot.sectionIdentifier(containingItem: sourceIdentifier) == snapshot.sectionIdentifier(containingItem: destinationIdentifier) // iii snapshot.deleteItems([sourceIdentifier]) // iv if isAfter { snapshot.insertItems([sourceIdentifier], afterItem: destinationIdentifier) } // v else { snapshot.insertItems([sourceIdentifier], afterItem: destinationIdentifier) } } } // 6 else { // i let destinationSectionIdentifier = snapshot.sectionIdentifiers[destinationIndexPath.section] // ii snapshot.deleteItems([sourceIdentifier]) // iii snapshot.appendItems([sourceIdentifier], toSection: destinationSectionIdentifier) } // 7 apply(snapshot, animatingDifferences: false) }