UICollectionView calls cellForItemAtIndexPath for absolutely all items in a data source on insertItemsAtIndexPath when created with CGSizeZero bounds size.

Originator:anthony
Number:rdar://26795511 Date Originated:6/14/2016
Status:Open Resolved:
Product:iOS Product Version:9.3.2
Classification:Bug Reproducible:Always
 
Summary:
If we insertItemsAtIndexPath into a UICollectionView which has been just created with CGRectZero frame (actually, CGSizeZero bounds.size), the collection view calls cellForItemAtIndexPath for absolutely all items in a data source. That means no cells get reused and therefore it can potentially allocate enormous amount of memory before it even went on screen.

In case of the flow layout, UICollectionViewFlowLayoutDelegate's sizeForItemAtIndexPath is never called either to get a proper cell size and see it's actually off bounds.

layoutAttributesForItemAtIndexPath on the layout object does get called and returns CGRectZero frame for all items. That's probably why the collection view thinks all items are visible and so creates cells for each of them.

To resolve this the collection view should never call cellForItemAtIndexPath until it has beed laid out and got any real initial bounds size instead of CGSizeZero.

As a workaround we could set a flag in layoutSubviews (for UICollectionView subclasses) or viewDidLayoutSubviews (for view controllers) and then call insertItemsAtIndexPath only after that flag is set, meaning the collection view had a chance to layout for the first time.

Steps to Reproduce:
Compile and run the following code:

#define LOAD_ALL_CELLS YES  // Change to NO to load only visible cells as it should be.

@interface ViewController : UIViewController<UICollectionViewDelegate, UICollectionViewDataSource>
@end

@implementation ViewController {
    UICollectionView *_collectionView;
    NSUInteger _numberOfItems;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
    UIView *view = self.view;

    // Let's setup a collection view with bounds.size equal either CGSizeZero or something else.
    // When bounds.size is CGRectZero the collection view will create cells for absolutely all items
    // in a data source on insertItemsAtIndexPaths no matter if they're evetually visible or not.
    // It doesn't ask the UICollectionViewFlowLayoutDelegate for the size of items either.
    CGPoint origin = view.bounds.origin;
    CGSize size = LOAD_ALL_CELLS ? CGSizeZero : view.bounds.size;
    _collectionView = [[UICollectionView alloc] initWithFrame:(CGRect){.origin = origin, .size = size}
                                         collectionViewLayout:[UICollectionViewFlowLayout new]];

    // Following with some typical workflow.
    _collectionView.translatesAutoresizingMaskIntoConstraints = NO;
    _collectionView.delegate = self;
    _collectionView.dataSource = self;
    [_collectionView registerClass:[UICollectionViewCell class]
        forCellWithReuseIdentifier:NSStringFromClass([UICollectionViewCell class])];
    [view addSubview:_collectionView];
    [NSLayoutConstraint activateConstraints:@[
      [view.leadingAnchor constraintEqualToAnchor:_collectionView.leadingAnchor],
      [view.topAnchor constraintEqualToAnchor:_collectionView.topAnchor],
      [view.trailingAnchor constraintEqualToAnchor:_collectionView.trailingAnchor],
      [view.bottomAnchor constraintEqualToAnchor:_collectionView.bottomAnchor],
    ]];

    // Now let's load some data.
    NSArray<NSIndexPath *> *indexPaths = @[
      [NSIndexPath indexPathForItem:0 inSection:0],
      [NSIndexPath indexPathForItem:1 inSection:0],
      [NSIndexPath indexPathForItem:2 inSection:0],
    ];
    [_collectionView numberOfItemsInSection:0];  // See rdar://26484150 on why this is needed.
    _numberOfItems = indexPaths.count;  // Modify the model before inserting items.

    // The following loads exactly _numberOfItems distinct cells if LOAD_ALL_CELLS == YES.
    [_collectionView insertItemsAtIndexPaths:indexPaths];
}

- (NSInteger)collectionView:(UICollectionView *)collectionView
     numberOfItemsInSection:(NSInteger)section {
    return _numberOfItems;
}

// When collectionView.bounds is CGRectZero this method is called for absolutely ALL items.
// Generated cells are all unique, so enormous amount of memory can be allocated.
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
                  cellForItemAtIndexPath:(NSIndexPath *)indexPath {
    UICollectionViewCell *cell =
        [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass([UICollectionViewCell class])
                                                  forIndexPath:indexPath];
    cell.backgroundColor = [UIColor blueColor];
    return cell;
}

// When collectionView.bounds is CGRectZero this method is never called.
- (CGSize)collectionView:(UICollectionView *)collectionView
                  layout:(UICollectionViewLayout*)collectionViewLayout
  sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
    return collectionView.bounds.size;
}

@end

Expected Results:
UICollectionView doesn't create cells for all items in a data source when it's initial bounds.size is CGSizeZero on insertItemsAtIndexPath.

Ideally it should only ask for the number of items in affected sections on insertItemsAtIndexPath and never try to load cells if it wasn't laid out itself just yet.

Actual Results:
UICollectionView creates cells for absolutely all items in a data source and therefore consumes an enormous amount of memory.

Comments


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!