Aleksandar • Vacić

iOS bits and pieces

UICollectionViewCell woes with bounds change

Back in August last year I wrote about the issues I had with UICollectionViewCell and how it’s was impossible to have them behave properly when bounds of their container (UICollectionView) change.

These days I’m trying to get to the bottom of this and have to say – it still escapes me. I have actually found a combination of calls that somewhat resolve that issue, but it’s stupid-ass workaround. There has to be something I’m missing here.

Update (Jul 6th): fixed, see bottom of the post.

There’s a demo project on GitHub and here’s a video demo of the issue inside my app Unitica.

You can see that when bounds change, the cells are left with the older layout. But as soon as you scroll the collection view just a bit – which forces cell redraw – they are properly displayed. This makes it obvious that layout is properly re-calculated, just not properly rendered in due time.

The setup

These are important pieces of code that I’m using; parent container which has two UICollectionViewController (columns):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)processCalculatorKeypadVisible:(BOOL)isShown {
  [self updateEdgeConstraintForKeyboardAppear:isShown];
  
  [UIView animateWithDuration:.4
                        delay:0
       usingSpringWithDamping:.9
        initialSpringVelocity:20
                      options:0
                   animations:^{
                       [self.view layoutIfNeeded];
                   } completion:^(BOOL finished) {

                   }];
}

This layoutifNeeded will perform its auto-layout magic and trigger bounds change for each of the embedded collection views.
The UICollectionViewLayout subclass responds to bounds changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
  
  self.shouldRecalculateLayout = (CGRectGetWidth(newBounds) != CGRectGetWidth(self.collectionView.bounds) ||
                                  CGRectGetHeight(newBounds) != CGRectGetHeight(self.collectionView.bounds));

  return YES;
}

- (void)prepareLayout {
  [super prepareLayout];
  
  if (self.shouldRecalculateLayout || !self.layoutInfo) {
      // RE-CALCULATE layout for new bounds

      self.shouldRecalculateLayout = NO;
  }
}

The cells have couple of custom LayoutAttributes and will re-layout when those are applied thus in theory the supplied .frame should be properly rendered.

1
2
3
4
5
6
- (void)applyLayoutAttributes:(RTUnitLayoutAttributes *)layoutAttributes {
  

  [self setNeedsUpdateConstraints];
  [self setNeedsLayout];
}

The problem

If you watch the video above again, you can see subview’s layout is done properly for everything but the cells. I have verified this by logging out each and every step of the way.

First, inside the animation block, layoutIfNeeded fires off:

1
__57-[RTColumnViewController processCalculatorKeypadVisible:]_block_invoke

Column’s layout responds to this, properly calculates layout for the new item size, with width=175 instead of 272:

1
2
3
-[RTUnitLayout shouldInvalidateLayoutForBoundsChange:] : YES, {0, 1014}, {272, 375} -> {0, 1014}, {175, 375}
-[RTUnitLayout prepareLayout], bounds={0, 1014}, {175, 375}, itemsize={175, 89}
-[RTUnitLayout prepareForAnimatedBoundsChange:] : {0, 1014}, {272, 375} -> {0, 1014}, {175, 375}

Collection View then queries the layout attributes for each item:

1
2
3
4
-[RTUnitLayout layoutAttributesForElementsInRect:] : rect={0, 667}, {175, 1334}
__50-[RTUnitLayout layoutAttributesForElementsInRect:]_block_invoke_2 : <NSIndexPath: 0xc000000000048016> {length = 2, path = 0 - 9} frame={0, 801}, {175, 89}
__50-[RTUnitLayout layoutAttributesForElementsInRect:]_block_invoke_2 : <NSIndexPath: 0xc000000000058016> {length = 2, path = 0 - 11} frame={0, 979}, {175, 89}
...

I am also utilizing in/out animations and that data is also properly returned:

1
2
-[RTUnitLayout finalLayoutAttributesForDisappearingItemAtIndexPath:] : <NSIndexPath: 0xc000000000058016> {length = 2, path = 0 - 11} frame={0, 979}, {272, 89}
-[RTUnitLayout initialLayoutAttributesForAppearingItemAtIndexPath:] : <NSIndexPath: 0xc000000000058016> {length = 2, path = 0 - 11} frame={0, 979}, {175, 89}

However, here’s the issue: what’s missing now is the step that actually gets the final layoutAttributes for each visible item. It should be this:

1
-[RTUnitCell applyLayoutAttributes:] : frame={0, 0}, {175, 89}

Since that’s not called, layoutSubviews is still operating with old cell bounds:

1
2
3
-[RTUnitCell layoutSubviews] : pre-super: {0, 979}, {272, 89}
-[RTUnitCell layoutSubviews] : post-super: {0, 979}, {272, 89}
...

And the animation finishes, again with all the proper data:

1
2
3
-[RTUnitLayout finalizeAnimatedBoundsChange] : {0, 1014}, {175, 375}

__57-[RTColumnViewController processCalculatorKeypadVisible:]_block_invoke623 : completed

For the life of me, I can’t figure out how to force the setNeedsLayout for all the cells. I tried various things and tricks I could think off, even enumerating visible cells and calling setNeedsLayout on each of them; no dice.

The workaround

Stupid workaround I mentioned is to abuse reloadData to trigger layout:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)processCalculatorKeypadVisible:(BOOL)isShown {
  [self updateEdgeConstraintForKeyboardAppear:isShown];
  
  [UIView animateWithDuration:.4
                        delay:0
       usingSpringWithDamping:.9
        initialSpringVelocity:20
                      options:0
                   animations:^{
                       [self.view layoutIfNeeded];
                       [self.leftController.collectionView reloadData];
                       [self.rightController.collectionView reloadData];
                   } completion:^(BOOL finished) {
                       [self.leftController.collectionView reloadData];
                       [self.rightController.collectionView reloadData];
                   }];
}

Even this in some weird cases will fail to refresh all cells, but in works in say 19/20 cases.

Update (Jun 26th)

I used up one of my DTS slots for this issue and Apple engineer gave me a workaround which seems to work just fine. I changed it a bit to cover all possible cases in my app but in essence: it loops thorugh visibleCells and manually sets the frame.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
- (void)processCalculatorKeypadVisible:(BOOL)isShown {
  [self updateEdgeConstraintForKeyboardAppear:isShown];
  
  [UIView animateWithDuration:.4
                        delay:0
       usingSpringWithDamping:.9
        initialSpringVelocity:20
                      options:0
                   animations:^{
                       [self.view layoutIfNeeded];
                       [self processWorkaroundForCollectionView:self.leftColumnController.collectionView];
                       [self processWorkaroundForCollectionView:self.rightColumnController.collectionView];
                   } completion:^(BOOL finished) {
                   }];
}

- (void)processWorkaroundForCollectionView:(UICollectionView *)collectionView {
  
  CGFloat width = collectionView.bounds.size.width;
  
  [collectionView.visibleCells enumerateObjectsUsingBlock:^(UICollectionViewCell *obj, NSUInteger idx, BOOL *stop) {
      CGRect frame = obj.frame;
      frame.size.width = width;
      obj.frame = frame;
  }];
}

He did suggest it’s a possible bug in the API, thus I also filed a bug report. Phew. :)

The proper solution

Beats me, for now.

If you have an idea, please @radiantav me on Twitter. Again: demo project is on GitHub, simply use iPhone 6 Plus simulator and switch to landscape.

Update (Jul 6th)

I did file a bug report with Apple (21570652) which just came back; the unnamed engineer found the true cause of this issue. I am using a custom UICollectionViewLayoutAttributes and had this in it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (BOOL)isEqual:(id)other {
  if (other == self) {
      return YES;
  }
  if (!other || ![[other class] isEqual:[self class]]) {
      return NO;
  }
  if ([((RTUnitLayoutAttributes *) other) screenViewCenterOffset] != [self screenViewCenterOffset]) {
      return NO;
  }
  if ([((RTUnitLayoutAttributes *) other) numberValueSwitched] != [self numberValueSwitched]) {
      return NO;
  }
  if ([((RTUnitLayoutAttributes *) other) isSource] != [self isSource]) {
      return NO;
  }
  if ([((RTUnitLayoutAttributes *) other) leftAligned] != [self leftAligned]) {
      return NO;
  }
  
  return YES;
}

The bug? That last line should actually be:

1
return [super isEqual:other];

Thank you unknown Apple hero. Now I’m going to go kick myself for the uncountable days I wasted on this.