UICollectionView invalid number of items crash problem and solution


Recently, I am working on an iOS project that has an UICollectionView in it. For updating items in the collection, I wrote code like this

collectionView.performBatchUpdates({
    for update in updates {
        switch update {
        case .Add(let index):
            collectionView.insertItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
        case .Delete(let index):
            collectionView.deleteItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
        }
    }
}, completion: nil)

Basically, whenever the data source updates the items, it runs this piece of code to insert or delete items in the UICollectionView. The code looks pretty straightforward, but sometimes it crashes. The exception looks like this

Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (1) must be equal to the number of items contained in that section before the update (1), plus or minus the number of items inserted or deleted from that section (1 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out).

Really odd right? I checked the number in data source, it’s correct. I thought it could be bug, then I googled around and found this on stackoverflow. It said that there is a bug in UICollectionView, to workaround that, you need to call reloadData when it’s empty, roughly like this:

if collection empty {
    collectionView.reloadData()
} else {
    collectionView.performBatchUpdates({
        for update in updates {
            switch update {
            case .Add(let index):
                collectionView.insertItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
            case .Delete(let index):
                collectionView.deleteItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
            }
        }
    }
}

Problem still not solved

Although I tried to reloadData() first then insert and delete, I still see crashes on the performBatchUpdates method call. It’s not really well-documented how performBatchUpdates works in details, so I decided to try it out to understand how it works.

func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    print("Get numberOfItemsInSection", section, items.count)
    return items.count
}

Then I saw something like

Get numberOfItemsInSection 0 1
collectionView.performBatchUpdates closure called
Get numberOfItemsInSection 0 1

So it turned out performBatchUpdates calls collectionView(_:numberOfItemsInSection:) first before call the given closure to know the item numbers. Next, it calls the closure, and eventually, it calls collectionView(_:numberOfItemsInSection:) again to check the number. And here is where the assertion exception thrown. Say if we insert a new item, and it sees

Okay, before one insert, the total item count is 1, let’s update.

Then

Job done, let’s check the item count again, wait, WTF? it’s still 1? Impossible, I just inserted one item!

When the story comes to this point, I finally understand why it throws that exception. My data source updates its item count first, then performBatchUpdates was called to update the UICollectionView. But the problem is, collectionView(_:numberOfItemsInSection:) returns the post-update item count, it confuses collectionView.performBatchUpdates why the item number is not changed correctly according to the updates we just did.

The solution

As if my understanding to how performBatchUpdates is correct, the item count returned by collectionView(_:numberOfItemsInSection:) should be sync with the updates made inside the closure. With this idea in mind, it’s easy to solve, just add a property as the item count and update it inside performBatchUpdates closure

func updateItems(updates: [ItemUpdate]) {
    collectionView.performBatchUpdates({
      for update in updates {
          switch update {
          case .Add(let index):
              collectionView.insertItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
              itemCount += 1
          case .Delete(let index):
              collectionView.deleteItemsAtIndexPaths([NSIndexPath(forItem: index, inSection: 0)])
              itemCount -= 1
          }
      }
  }, completion: nil)
}

and for the collectionView(_:numberOfItemsInSection:), instead of returning items.count, we return the property which is manually maintained by performBatchUpdates closure.

func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return itemCount
}

Then problem solved!