- To discuss the new declarative way of handling data sources in iOS.
- Know how to configure a
UITableViewDiffableDataSource
type. - Understand
NSDiffableDataSourceSnapshot
and 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
UITableViewDataSource
such astitleForHeaderInSection()
orcommit editingStyle:
then you have to subclassUITableViewDiffableDataSource
and match theSectionIdentifierType
and theItemIdentifierType
as 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
-
cellProvider
is 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
NSDiffableDataSourceSnapshot
which 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
startCountdown
method. - Add the
decrementCounter
method. - Add the
ship
method. - Add a
refresh
bar 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
enum
called 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
Hashable
protocol in order to be anItemIdentifierType
on theUITableViewDiffableDataSource
. We can also define which property to be made the hashable value using thefunc hash(into hasher:)
method.
-
Subclass
UITableViewDiffableDataSource
so we are able to implement the necessaryUITableViewDataSource
methods 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
filter
to 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
sectionIdentifiers
on 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)
}