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.