The Hidden Trap in Selective Table View and Collection View Reloads

Illustration: The Hidden Trap in Selective Table View and Collection View Reloads

UITableView and UICollectionView are two essential building blocks of most iOS application UIs and also two of the most ubiquitous classes in the iOS community. Additionally, they offer many similar capabilities for presenting your application data. One of the shared features is a selective batch update system, which can be used to update the displayed data and optionally accompany those updates with slick animations. While this batch update system is very powerful, it has also always been fairly easy to fall into one of the many traps that these batch updates hold.

As a matter of fact, we recently stumbled upon one of these traps. During QA testing, we noticed that a PDF Viewer update was crashing while reordering a table view. Our investigation eventually led us to UITableView.reloadRows(at:with:) and UICollectionView.reloadItems(at:), both of which turned out to be inherently unsafe and incompatible with a general data update system that includes reordering of displayed items.

The Issue

The issue manifested itself on our annotation list, which shows the PDF annotations found on each page in a document. The order in the annotation list is the same as the order the annotations are stacked in when rendering the page (the z-order). The crash occurred when modifying the z-order of annotations, which is done via either our annotation inspector or reorder controls in the list itself.

In both cases, the z-order update should have led to an updated order in the list. However, if the list was visible during the update, we sometimes got an exception; the issue was not consistently reproducible. In addition, the exception message didn’t even make a lot of sense: It mentioned we were trying to delete the same item we were moving. But we weren’t really doing any deletions at all — we were just reordering:

Copy
1
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to perform a delete and a move from the same index path (<NSIndexPath: 0xe9f6160ca79619ee> {length = 2, path = 0 - 2})'

The update was backed by a diff object generated from our IGListKit-based diffing algorithm. Inspecting that reaffirmed that there were no deletions involved:

1
2
Printing description of indexPathDiff:
<PSPDFIndexPathDiff 0 deletes, 0 inserts, 1 updates, 2 moves>

The interesting part here is actually not the moves, but the single update. It turns out that the update was generated due to the table view also displaying the last change date for annotations. That changes when the annotations’ z-index is updated. But due to the date only having minute accuracy, we only generate updates if more than a minute has passed since the last change was made. That sure sounds like it could be the source of our indeterminism!

A quick look at the documentation for UITableView.reloadRows(at:with:) sheds more light on the problem:

“When this method is called in an animation block defined by the beginUpdates() and endUpdates() methods, it behaves similarly to deleteRows(at:with:). The indexes that UITableView passes to the method are specified in the state of the table view prior to any updates. This happens regardless of ordering of the insertion, deletion, and reloading method calls within the animation block.”

So, a reload is nothing more than a deletion and reinsertion at the same index. If that gets mixed in with a reorder operation, we’re in trouble! This also explains the exception message from earlier, which complains about a deletion and move happening at the same time.

This is an unfortunate limitation of UIKit. Updating a cell and moving it at the same time is a common operation, and it’s likely that an item is being reordered precisely because it changed. This also essentially means that it’s not safe to perform updates together with other table view updates generated from a diffing algorithm.

The Fix

If you have any sort of generalized table view or collection view update helper, be sure to throw out reload calls. They are a bad idea!

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func updateWithDiff(indexPathDiff: IndexPathDiff, deleteRowAnimation: RowAnimation = .automatic, insertRowAnimation: RowAnimation = .automatic, updateRowAnimation: RowAnimation = .automatic) {
    deleteSections(indexPathDiff.sectionDeletes, with: deleteRowAnimation)
    insertSections(indexPathDiff.sectionInserts, with: insertRowAnimation)
    reloadSections(indexPathDiff.sectionUpdates, with: updateRowAnimation)

    for indexPathMove in indexPathDiff.sectionMoves {
        let from = indexPathMove.from[0]
        let to = indexPathMove.to[0]
        moveSection(from, toSection: to)
    }

    deleteRows(at: indexPathDiff.rowDeletes, with: deleteRowAnimation)
    insertRows(at: indexPathDiff.rowInserts, with: insertRowAnimation)
    reloadRows(at: indexPathDiff.rowUpdates, with: updateRowAnimation) // Bad idea!

    for indexPathMove in indexPathDiff.rowMoves {
        moveRow(at: indexPathMove.from, to: indexPathMove.to)
    }
}

Instead, you should extract your cell configuration code into a helper and manually reconfigure cells that change. The simplest approach is to do it right after the batch updates for all visible cells:

Copy
1
2
3
4
5
for indexPath in tableView.indexPathsForVisibleRows ?? [] {
    if let cell = tableView.cellForRow(at: indexPath) {
        self.configure(cell, forRowAt: indexPath, inTableView: tableView)
    }
}

You can also be a bit more selective and only update the rows that you know have changed. But depending on what your diffing algorithm generates, it might be tricky in some cases to figure out the correct index path to update — in particular, if you’re trying to perform those updates in the middle of the update block, you might find that the index paths you have available are not compatible with the data source and/or UI state at the moment when those updates are invoked.

A New Hope

As you saw in the previous paragraphs, our problem with UITableView.reloadRows(at:with:) was eventually easily solvable by just switching to an alternative approach for cell updates. However, we still had to waste quite a bit of time investigating the somewhat cryptic exception to arrive at the root of the problem. Fortunately, all of this, along with other batch update gotchas — like needing to mindful about when to update the data source — will hopefully soon be history. The diffable data sources API promises to rid us of a lot of boilerplate code when working with table or collection views, as well as provide a more robust API for applying updates via NSDiffableDataSourceSnapshot. For more information on this topic, I’d recommend the excellent Advances in UI Data Sources talk from WWDC 2019.

We’re looking forward to switching to this new API as soon as our somewhat generous version support policy allows for it.

PSPDFKit for iOS

Download the free 60-day trial and add it to your app today.