Diffable Datasources in iOS 13

Episode #415 | 13 minutes | published on October 25, 2019 | Uses iOS-13.1, Xcode-11.1
Subscribers Only
One of the features of iOS 13 that has not gotten much attention is the new diffable datasources for UITableView and UICollectionView. Using UITableViewDiffableDataSource or UICollectionViewDiffableDataSource along with NSDiffableDataSourceSnapshot you can create safe, animatable changes between two states without having to keep track of which records were added, moved, or deleted. It's seriously great!

Introducing Diffable DataSource

Diffable datasource calculates the difference between two snapshots and applies these changes with an appropriate animation.
Before iOS 13, this required a significant amount of code and often times we would just give up and call reloadData.

Now with the UITableViewDiffableDataSource you can create datasource and animate the changes between states.

Creating a Data model

We'll start with an example of grouped episodes by one of their tags. To simulate a delete operation, we'll delete random data using a filter on an episode array and then we'll animate the data before displaying the next set of the table view.

private var episodes: [Episode] = [] {
    didSet {
        groupedEpisodes = episodes.reduce([:], { (groups, episode) -> [String: [Episode]] in
            var newGroups = groups
            let tagGroup = episode.tags.sorted().first ?? "xxx No group xxx"
            newGroups[tagGroup] = (groups[tagGroup] ?? []) + [episode]
            return newGroups
        })
    }
}

private func munge(_ episodes: [Episode]) -> [Episode] {
    return episodes.filter { _ in Bool.random() }.shuffled()
}

@IBAction func refresh() {
    if _downloadedEpisodes == nil {
        showLoading()
        API.fetchEpisodes { episodes in
            self._downloadedEpisodes = episodes
            self.hideLoading()
            self.episodes = self.munge(episodes)
        }
    } else {
        self.episodes = self.munge(_downloadedEpisodes!)
    }
}

private var groupedEpisodes: [String : [Episode]] = [:] {
    didSet {
        tableView.reloadData
        datasource.apply(snapshot)
    }
}

Conform Your Model to Hashable

The data we are working with has to be Hashable to work with snapshots. Here each item will contain an ID for identification. In the cases where the item is inserted randomly, you can easily generate random identifiers using UUID. In our case the built-in Hashable implementation will suffice, but you may have different needs.

struct Episode : Decodable, Hashable {
    let id: Int
    let title: String
    let tags: [String]
}

We have to define what a section is. Sometimes this will be a custom type, but in our case it is just a string. To make the point clear, we'll use a typealias and use this when referring to sections.

typealias Section = String

Configure Datasource Using UITableViewDiffableDataSource

Next, we'll create a datasource using UITableViewDiffableDataSource and have a snapshot of the data representing the UI.

private var datasource: UITableViewDiffableDataSource<Section, Episode>!
private var snapshot: NSDiffableDataSourceSnapshot<Section, Episode>!

Here we use the cellProvider closure to dequeue and configure our table view's cell.

private func configureDatasource() {
    datasource = UITableViewDiffableDataSource(tableView: tableView,
                                               cellProvider: { (tableView, indexPath, episode) -> UITableViewCell? in
                                                let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
                                                cell.textLabel?.text = episode.title
                                                return cell
    })
}

Creating a Snapshot

We'll replace the reloading of the table view by creating a new snapshot. This snapshot contains all the sections with its grouped items, once ready it is applied to the datasource.


private var groupedEpisodes: [String : [Episode]] = [:] {
    didSet {
        snapshot = NSDiffableDataSourceSnapshot()
        let sections = groupedEpisodes.keys.sorted()
        snapshot.appendSections(sections)

        for section in sections {
            let group = groupedEpisodes[section]!
            snapshot.appendItems(group, toSection: section)
        }

        datasource.apply(snapshot)
    }
}

Applying the Grouping to the datasource

Once we add the snapshot to the datasource, the grouping of the data is lost. This might be a bug, but we can restore this behavior by implementing viewForHeaderInSection: and returning our own view.

override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    let section = groupedEpisodes.keys.sorted()[section]
    let label = UILabel()
    label.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
    label.backgroundColor = .systemGray5
    label.text = section
    return label
}

The downside to doing it this way is that we have to take care to match exactly what the stock header views resemble. With a little more work we can workaround this limitation and use stock headers (albeit with some boilerplate code).

Restoring the Stock Header View Behavior

To restore this behavior, we'll set the tableView's dataSource back to self. We'll then implement and pass behavior off to the datasource we created earlier. This gives us control of returning a string again for titleForHeaderInSection:.

private func configureDatasource() {
    datasource = UITableViewDiffableDataSource(tableView: tableView,
                                               cellProvider: { (tableView, indexPath, episode) -> UITableViewCell? in
                                                let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
                                                cell.textLabel?.text = episode.title
                                                return cell
    })
    //Uncomment this to restore the stock header view behavior
    tableView.dataSource = self
}

// Comment this to restore the stock header view behavior
//override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
//    let section = groupedEpisodes.keys.sorted()[section]
//    let label = UILabel()
//    label.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
//    label.backgroundColor = .systemGray5
//    label.text = section
//    return label
//}

// Uncomment these to restore the stock header view behavior
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    let section = groupedEpisodes.keys.sorted()[section]
    return section
}

override func numberOfSections(in tableView: UITableView) -> Int {
    return datasource.numberOfSections(in: tableView)
}

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return datasource.tableView(tableView, numberOfRowsInSection: section)
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return datasource.tableView(tableView, cellForRowAt: indexPath)
}

So now when we refresh the episodes we'll see an animated view with groups added over the section.

blog comments powered by Disqus