Batch-Updating UICollectionView
- iOS SDK
Recently I needed to update the contents of a UICollectionView
. Simple right? Just call reloadData
and be done with it.
However this results in poor UX because users don’t get the visual feedback of how items got reordered. Of course the solution is to animate insert/delete/move items in the performBatchUpdates
block, but how can we get from the old list of items to the new one?
Sample Application
Suppose we have an array of integers that we are currently displaying in the collection view. When a user taps on a button, we want to display a different list of integers, potentially including numbers that were present in the original list.
Luckily all of the animation is builtin; the only thing we need to do is tell the collection how to change. The order to accomplish this is to delete, then move, and finally insert.
The code
Here is the high level of what we are going to do. Each step is encapsulated inside its own update block to make it more clear, as well as to ensure our data source is in sync with the internal collection view.
func setCollectionViewItems(newItems: [Int]) {
// self.items = [0, 1, 2, 3, 4, 5, 6, 7, 8]
// Delete old items
collectionView.performBatchUpdates({ ... })
// Move existing items to correct order
collectionView.performBatchUpdates({ ... })
// Insert new items
collectionView.performBatchUpdates({ ... })
}
Step 1: Delete
One thing to note is that UICollectionView
only allows a single delete at any given index path inside a performBatchUpdates
block. For example, if we want to delete the first two elemnts, we can’t call delete at index path 0
twice like we could an array.
This means that we cannot simply iterate and delete as we go along, because deleting an element shifts every element after by one. Thus, the indices would be incorrect (bad) and we might call delete twice with the same index (worse!). To get around this, we can simply reverse the iteration order so that we start with the last element, as this does not change future indices that are iterated.
// Delete old items
collectionView.performBatchUpdates({
// Perform this in reverse to maintain correct indices
for (i, item) in self.items.enumerate().reverse() {
guard newItems.indexOf(item) == nil else { continue }
self.items.removeAtIndex(i)
self.collectionView.deleteItemsAtIndexPaths([ NSIndexPath(forItem: i, inSection: 0) ])
}
}, completion: nil)
Step 2: Move
The goal of the second step is to move the existing items into the correct order in the new list. (This can be further optimized by creating a dictionary containing the mapping from item to index instead of performing a linear search every time.)
// Move existing items to correct order
collectionView.performBatchUpdates({
var index = 0
for item in newItems {
guard let fromIndex = self.items.indexOf(item) else { continue }
self.collectionView.moveItemAtIndexPath(
NSIndexPath(forItem: fromIndex, inSection: 0), toIndexPath:
NSIndexPath(forItem: index, inSection: 0))
index += 1
}
}, completion: nil)
Step 3: Insert
Finally we compute and create index paths that correspond to the new items, assign the newItems
to our instance variable, and give the collection view the array of new indices to insert. (Again, a slight optimization is to compute the new index paths inside the guard
statement of the second step.)
// Insert new items
collectionView.performBatchUpdates({
let newIndexPaths = newItems.enumerate().filter({ (index, item) -> Bool in
return !self.items.contains(item)
}).map({ (index, item) -> NSIndexPath in
return NSIndexPath(forItem: index, inSection: 0)
})
self.items = newItems
self.collectionView.insertItemsAtIndexPaths(newIndexPaths)
}, completion: nil)
Summary
Hopefully this shows that it doesn’t take a lot of additional work to create a much better UX with animation. A demo project is available here.