The story begins with the fatal error, which no one in a 20-people team can reproduce, on the FireBase crashlytics of our app.
Fatal Exception: NSInternalInconsistencyException
UICollectionView received layout attributes for a cell with an index path that does not exist: <NSIndexPath: 0xff5ad67c02a13920> {length = 2, path = 0–0}
Actually this crash happens only on iOS12, so many of my friends talks me out of it since OS system seems to fix this issue.
But I just couldn’t resist to understand the truth of it. So the journey started with…
Refactoring
Why refactoring? Since no one can reproduce the crash, the only clue we have is through the COLLECTIONVIEW.
As I digging into the code, I realize that the origin coder had used massive amount of
collectionView.reloadData()
And not only we did a lot of reloadData(), but also we usually have something done after that. (Note 1) After we change the calling flow and reduce the amount of reloadData(), we finally found out the crash step.
- Show the collectionView with data.
- Shrink the collectionView with height = 0.
- Set the data empty.
- Expand the collectionView with height > 0
This is causing crash because when you reloadData() under 0 height or width of collectionView, it does not actually trigger layout redraw. And after you set the layout to height > 0, the layout of collectionView tries to retrieve the layoutAttribute with indexPaths before you shrank it, which doesn’t exist anymore.
There are 3 approach to solve this problem,
1. Hide the collectionView instead of set the height to 0.
If we do this,
collectionView.isHidden = true
collectionView.reloadData()
The layout still works fine. So we would not have this crash.
But some circumstances does not allow we do this. Like our app, the collectionview is a tool menu for user, and it should be shrunk with animation if user wants to hide it.
Then you may try the 2nd approach,
2. Customizing the collectionViewFlowLayout
private var cache: [UICollectionViewLayoutAttributes] = []()override func prepare() { cache.removeAll()}
You may check this for the thorough approach of how to custom a collectionViewFlowLayout
3. Setting values for estimatedItemSize of collectionViewLayout.
estimatedItemSize is a way apart from implementing the function in collectionViewFlowlayoutDelegate:
collectionView(_:layout:sizeForItemAt:)
Usually we implement the sizeForItemAt func to assign the cell size. But if you’re using estimatedItemSize, the collectionView will honor your AutoLayout to determine the cell size dynamically. And if you want to decide the cell size programmatically, you may override the function for cell:
preferredLayoutAttributesFittingAttributes
Note 1
For a small tip giving to you. If you want to do something after reloadData(), and expecting the layout of collectionView is changed, you should use the approach below,
collectionView.reloadData()
collectionView.layoutIfNeed()
//some UI function you want to do after.
There are some other approach you may find online, including
1. performBatchUpdates
collectionView.performBatchUpdates({}) { (finish) -> Void in
//some UI function you want to do after.
}
This often does the trick, however, if we print the delegate method,
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell
We realize that the function is not called equal amount as reloadData() does. This means it may have some old cell not reloaded, which leads to UI error.
2. DispatchQueue.main.async
collectionView.reloadData()
DispatchQueue.main.async {
//some UI function you want to do after.
}
This is actually work on iOS12, but in iOS13, the calling sequence will be:
- reloadData() -> not update layout yet.
- DispatchQueue.main.async -> do your thing with wrong UI
- Data reloaded, layout redraw.
So Basically, these two approach is not the recommended.