UICollectionView prefetching can cause display of pages hidden for reuse

Originator:matej
Number:rdar://39604024 Date Originated:20-Apr-2018 08:48 PM
Status:Open Resolved:
Product:iOS + SDK Product Version:iOS 10 and 11
Classification:Serious Bug Reproducible:Always
 
Summary:
In the PSPDFKit framework we use a collection view with custom layouts as the main component for document navigation. After removing our own prefetching code and switching to native UICollectionView prefetching, we noticed that when invoking some specific navigation steps and triggering `invalidateLayout` on the collection view layout, we could end up in a state where cells with `_isHiddenForReuse` set to `YES` were displayed in the visible area of the collection view. 

Steps to Reproduce:
I'm attaching a GIF illustrating this behavior. Showing or hiding the navigation bar causes an `invalidateLayout` call on iOS 11, due to changes in the `safeAreaInsets`. That's why we first observed the issue on iOS 11. The same issue occurs on iOS 10, if we trigger `invalidateLayout` manually. 

I tried reproducing the same problem in an isolated sample, but was unable to do so. I'm however providing some more detailed findings during my debugging session that lead me to believe that this is not an issue on our side. 

Expected Results:
Visible cells would always be visible `_isHiddenForReuse = NO`. 

Actual Results:
With prefetching enabled, cells can become part of the `visibleCells` list with `_isHiddenForReuse = YES`. Those cells do not show up on screen. Even in the view debugger. They can however be accessed via the `visibleCells` property. 

To trigger this behavior the prefetching state (prefetched cells) need to be in specific configurations and a `invalidateLayout` call needs to be triggered. 

Version:
iOS 10 and 11

Notes:
See DebuggingNotes.txt for a detailed description of the problematic state observed in UIKit.

Comments

Behavior

https://cloudup.com/cBIZsBlBlv9

DebuggingNotes.txt

With prefetching enabled UICollectionView uses a cache for pages that are loaded, but not yet displayed on screen. This is a dictionary <NSIndexPath : _UICollectionViewPrefetchItem>, stored under the _prefetchCacheItems instance variable.

_UICollectionViewPrefetchItem stores the layout attributes and reusable view (UICollectionViewCell in our case) for a specific index path. Views that are put into _prefetchCacheItems also get their visibility set to "hidden". This is however not via the default hidden property, bit via dedicated methods _isHiddenForReuse and _setHiddenForReuse: defined on UIView. hidden reflects the value of _isHiddenForReuse, but does not change it. When prefetched cells are determined to become visible, they are removed from the cache and _isHiddenForReuse is set to NO so they show up.

If the collection view layout gets invalidated (which happens for us when we show or hide the navigation bar), the prefetch cache gets cleared. During this, new layout attributes and potentially new prefetched cells might get queried from the layout / data source. Those cells might not match the previous prefetched cells (e.g., only cells in the last scrolled direction are preloaded again).

Here's where the problem appears to happen. During cache eviction, the removed cells do not get their _isHiddenForReuse value updated. It remains the same. Some of those cells then get re-added to new _UICollectionViewPrefetchItem objects, which ensures their _isHiddenForReuse is updated when the cell comes on screen. But some cells don't get "prefetched" again. There is no _UICollectionViewPrefetchItem created for them. Their _isHiddenForReuse value remains set and they remain at the same position they were before invalidating the layout. UICollectionView appears to be perfectly ok with thinking there's nothing wrong with that cell and just keeps it in position as is. It even sends out "will appear" delegate calls for it. But since the cell is hidden, we can't see anything.

I verified that manually ensuring _isHiddenForReuse is not set for every visible cell during layout passes fixes the issues. Prefetching appears to work fine after that.


Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!