iOS 13 - Diffable Data source for Table View and Collection View

iOS 13 - Diffable Data source for Table View and Collection View
Before you begin this tutorial, please make sure are running this tutorial on Xcode 11.0 with device running iOS 13 or more

This year's WWDC was spectacular, not just because of introduction to SwiftUI, but because of the release of many other amazing APIs too. On the same note, today I would like to talk about one such construct - Diffable data sources for both UITableView and UICollectionView presented in session 220 of WWDC - 2019 titled under Advances in UI Data Sources.

We will see the usage and demonstration of these diffable data sources for both constructs,

Before I begin, I urge, if you haven't done so, please watch the WWDC - 2019 session 220 - Advances in UI Data Sources. This is really informative session with extraordinary coding sample and demos

First, let's start with simple storyboard. We want to use two screens. One for UITableView and other for UICollectionView. We will utilize the home page with buttons to jump to either pages.

If we take a look at vanilla storyboard, this is how it will look like with just one home screen and two other screens - One for tableView and the other for collectionView

Now, let's start adding few more elements to this storyboard.

  1. Table View and Cell
  2. Collection View and Cell
  3. Search bar for both to be able to search from the list of items (Here, for example - countries)

After adding these UIKit elements and binding them with appropriate constraints, this is how it will look like,

Few things to note,

  1. Please set delegate to both search bars as their corresponding owning ViewControllers
  2. I have added bit more complicated UICollectionViewCell for this example. But this is not necessary. You can add any custom/base cell to your collection view
  3. There is no need to set up dataSource. It will all be taken care of by new APIs from iOS 13. Although, you might want to set up delegates to be able to intercept row taps
  4. Create two controllers TableViewController and CollectionViewController respectively and set them up to appropriate screens in storyboard
  5. Finally, this is how our folder structure will look like with all the elements grouped by responsibilities and types

When we run the project, there is nothing fancy. We will see blank views for both screens as we haven't yet set up appropriate data sources to be able to show the data.

  1. Diffable data source for TableView

In the first example we will see how to set a diffable data source for tableView. To get started, let's start creating a model object of named Country which will act as a data for our table view.

struct Country: Hashable {
    let name: String
    let identifier = UUID()

    func hash(into hasher: inout Hasher) {
        hasher.combine(identifier)
    }

    static func ==(lhs: Country, rhs: Country) -> Bool {
        return lhs.identifier == rhs.identifier
    }

    func contains(query: String?) -> Bool {
        guard let query = query else { return true }
        guard !query.isEmpty else { return true }
        let lowerCasedQuery = query.lowercased()
        return name.lowercased().contains(lowerCasedQuery)
    }
}

We will use the similar construct for both TableView and CollectionView demo
  1. Make sure any custom type conforms to Hashable This is important for diffable data source to work
  2. Every custom type should have the unique identifier. In this case I have used UUID as a unique identifier for objects of Country type
  3. The object must also conform to Equatable protocol
  4. We also have a utility method named contains which when passed the substring will return if it matches with the name of the underlying country object

Next, in your TableViewController start with declaration of three properties on top - dataSource, which will act as a provider of data for our tableView, second is array of countries containing objects of type Country (In real life, these objects may be coming from local file or a server. For the sake of simplicity, we will hardcode this data in the ViewController file.) and third is IBOutlet of tableView from Storyboard.

In addition to it, we will also define a struct called Section which will house related sections. For now, we can assume that we just have one section named main. In practice though you may have more than just one section


enum Section: CaseIterable {
    case main
}

@IBOutlet weak var tableView: UITableView!
var countries: [Country] = []
var dataSource: UITableViewDiffableDataSource<Section, Country>!

Next step, we will initialize these properties in viewDidLoad method to make they are usable in later stages.

override func viewDidLoad() {
    super.viewDidLoad()
    tableView.delegate = self
    let countryNames = ["Afghanistan",
                 "Albania",
                 "Algeria",
                 "Andorra",
                 "Angola",
                 "Antigua and Barbuda",
                 "Argentina",
                 "Armenia",
                 "Australia",
                 "Austria",
                 "Azerbaijan",
                 "Bahamas",
                 "Bahrain",
                 "Bangladesh",
                 "Barbados",
                 "Belarus"]
    for name in countryNames {
        countries.append(Country(name: name))
    }
    dataSource = UITableViewDiffableDataSource
        <Section, Country>(tableView: tableView) {
            (tableView: UITableView, indexPath: IndexPath,
            country: Country) -> UITableViewCell? in
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = country.name
            return cell
    }
}

However, we are far from over at this point. Next step we want to do is to be able to search through search bar. We can do it by implementing delegate method func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) from UISearchBarDelegate.

We will add another method called performSearch which will take searchQuery as input. If query is nil or empty, we will populate tableView with original list of countries. If user inputs at least one characters, we will perform case-insensitive search on the list of existing Countries and display the result using UITableViewDiffableDataSource.

func performSearch(searchQuery: String?) {
    let filteredCountries: [Country]
    if let searchQuery = searchQuery, !searchQuery.isEmpty {
        filteredCountries = countries.filter { $0.contains(query: searchQuery) }
    } else {
        filteredCountries = countries
    }
    let snapshot = NSDiffableDataSourceSnapshot<Section, Country>()
    snapshot.appendSections([.main])
    snapshot.appendItems(filteredCountries, toSection: .main)
    dataSource.apply(snapshot, animatingDifferences: true)
}


There is lot going on inside this method

  1. First off, we check if user has entered at least one character in search bar or not. If search bar is cleared, we populate table view with the list of original countries
  2. If there is at least one character entered, we also do case-insensitive search on the list of country items
  3. We create a snapshot representing current state of data - In this case, list of filtered countries based on the input search string
  4. Next, we apply that snapshot to existing data source which takes care of reloading tableView with filtered list of items
  5. In order to be able to see the initial state, we will call performSearch from viewDidLayoutSubviews method with nil searchQuery which will show the original list of all the countries
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    performSearch(searchQuery: nil)
}

And this is how our little demo will look like with the TableView and accompanying diffable data source,

Finally, assuming you've already set up a UITableViewDelegate for the tableView, we can also get the name of currently selected country from the tableView using itemIdentifier API.

extension TableViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let country = dataSource.itemIdentifier(for: indexPath) {
            print("Selected country \(country.name)")
        }
    }
}

2. Diffable data source for CollectionView

Fortunately, there are lot of things that overlap between collection and table view and we will be able to re-use most of them.

Similar to TableView we will begin declaring three properties on top level

  1. IBOutlet of collectionView from the storyboard
  2. Array containing objects of type Country
  3. dataSource - Which will be used to provide data to collectionViewCells. Please note the difference - Unlike previous example, this dataSource will be of type UICollectionViewDiffableDataSource
override func viewDidLoad() {
    super.viewDidLoad()

    let countryNames = ["Afghanistan",
                        "Albania",
                        "Algeria",
                        "Andorra",
                        "Angola",
                        "Antigua and Barbuda",
                        "Argentina",
                        "Armenia",
                        "Australia",
                        "Austria",
                        "Azerbaijan",
                        "Bahamas",
                        "Bahrain",
                        "Bangladesh",
                        "Barbados",
                        "Belarus"]
    for name in countryNames {
        countries.append(Country(name: name))
    }

    dataSource = UICollectionViewDiffableDataSource
        <Section, Country>(collectionView: collectionView) {
            (collectionView: UICollectionView, indexPath: IndexPath,
            country: Country) -> UICollectionViewCell? in
            guard let cell = collectionView.dequeueReusableCell(
                withReuseIdentifier: "cell", for: indexPath) as? MyCellCollectionViewCell else {
                    fatalError("Cannot create new cell") }
            cell.descriptionLabel.text = country.name
            let screenWidth = collectionView.frame.size.width
            cell.widthConstraint.constant = (screenWidth/2.0) - (2 * 16.0)
            return cell
    }
}

Assuming we have similar performSearch method which takes searchQuery as an input, we can call it from viewDidLayoutSubviews to display original list of countries as well as when text is changed in the search bar.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    performSearch(searchQuery: nil)
}

It doesn't matter what kind of UICollectionCell subclass we're using here. For simplicity, I am using the custom collection view cell subclass which displays header on the top, and country name in its description.

extension CollectionViewController: UISearchBarDelegate {
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        performSearch(searchQuery: searchText)
    }
}

And this is how our final diffable collection view looks like with user action,

Overall, I am happy and feel more confident to start using UICollectionViews again! I always had an aversion for Collection views, just because they're hard to set up and a lot of things can go wrong if not paid enough attention, such as performance degradation and unexpected crashes. Does anyone remember this? (Thanks Apple!)

Terminating app due to uncaught exception ‘NSInternalInconsistencyException’, reason: ‘Invalid update: invalid number of sections. The number of sections contained in the collection view after the update (3) must be equal to the number of sections contained in the collection view before the update (5), plus or minus the number of sections inserted or deleted (0 inserted, 0 deleted).’

By introducing new Diffable data source API, Apple takes care of doing all the heavy lifting while updating contents of table or collection view so that developers can pay more attention to things that matter the most, such as integrating UI elements, features and related business logic.

The completed project is available on GitHub if you want to understand it more or extend on the top of the base implementation. Of course, if you have further questions, feel free to contact me through comments on this post or follow me on Twitter!

References:

  1. Advances in UI Data Sources - WWDC 2019
  2. UICollectionView with autosizing cell using autolayout