7

Consider an standard, vertically scrolling flow layout populated with enough cells to cause scrolling. When scrolled to the bottom, if you delete an item such that the content size of the collection view must shrink to accommodate the new number of items (i.e. delete the last item on the bottom row), the row of cells that scroll in from the top are hidden. At the end of the deletion animation, the top row appears without animation - it's a very unpleasant effect.

In slow motion:

Cells not appearing

It's really simple to reproduce:

  1. Create a new single view project and change the default ViewController to be a subclass of UICollectionViewController

  2. Add a UICollectionViewController to the storyboard that uses a standard flow layout, and change its class to ViewController. Give the cell prototype the identifier "Cell" and a size of 200x200.

  3. Add the following code to ViewController.m:


@interface ViewController ()
@property(nonatomic, assign) NSInteger numberOfItems;
@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.numberOfItems = 19;
}

- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section
{
    return self.numberOfItems;
}

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    return [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
}

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath
{
    self.numberOfItems--;
    [collectionView deleteItemsAtIndexPaths:@[indexPath]];
}

@end



Additional Info

I've seen other manifestations of this problem when dealing with collection views, it's just that the above example seems the simplest to demonstrate the issue. UICollectionView seems to go into some kind of paralysed state of panic during the default animations, and refuses to unhide certain cells until after the animation completes. It even prevents manual calls to cell.hidden = NO on hidden cells from having an effect (hidden is still YES afterwards). Dropping down to the underlying layer and setting hidden there works, provided you can get a reference to the cell you want to unhide, which is non-trivial when dealing with cells that haven't been displayed yet.

-initialLayoutAttributesForAppearingItemAtIndexPath is being called for every item visible at the time of the call to deleteItemsAtIndexPaths:, but not for the ones that are scrolled into view. It is possible work around the issue by calling reloadData inside a batch update block immediately afterwards, which appears to make the collection view realise that the top row is about to appear:

[collectionView deleteItemsAtIndexPaths:@[indexPath]];
[collectionView performBatchUpdates:^{
    [collectionView reloadData];
} completion:nil];

But unfortunately this is not an option for me. I am trying to implement some custom animation timing by manipulating the cell layers & animations, and calling reloadData really throws things out of whack by causing unnecessary layout callbacks.



Update: A bit of investigation

I added log statements to a lot of layout methods and looked through some stack frames to try and find out what's going wrong. Crucially, I'm checking when layoutSubviews is called, when the collection view asks for layout attributes from the layout object (layoutAttributesForElementsInRect:) and when applyLayoutAttributes: is called on the cells.

I would expect to see a sequence of methods like this:

// user taps cell (to delete it)
-deleteItemsAtIndexPaths:
-layoutAttributesForElementsInRect:
-finalLayoutAttributes...:                // Called for the item being deleted
-finalLayoutAttributes...:                // \__ Called for each index path visible
-initialLayoutAttributes...:              // /   when deletion started
-applyLayoutAttributes:                   // Called for the item being deleted, to apply final layout attributes
// collection view begins scrolling up
-layoutSubviews:                          // Called multiple times as the 
-layoutAttributesForElementsInRect:       // collection view scrolls
// ... for any new set of
// ... attributes returned:
-collectionView:cellForItemAtIndexPath:
-applyLayoutAttributes:                   // Sets the standard attributes for the new cell
// collection view finishes scrolling

Most of this is happening; layout is correctly triggered as the view scrolls, and the collection view properly queries the layout for the attributes of cells to be displayed. However, collectionView:cellForItemAtIndexPath: and the corresponding applyLayoutAttributes: methods are not being called until after the deletion, when layout is invoked one last time causing the hidden cells to be assigned their layout attributes (sets hidden = NO).

So it seems that despite receiving all the correct responses from the layout object, the collection view has some kind of flag set to not update the cells during the update. There is a private method on UICollectionView called from within layoutSubviews that seems responsible for refreshing the cells' appearance: _updateVisibleCellsNow:. This is from where the data source eventually gets asked for a new cell before applying the cells starting attributes, and it seems this is the point of failure, as it is not being called when it should be.


Additionally, this does seem to be related to the update animation, or at least cells are not updated for the duration of the insertion/deletion. For example the following works without glitches:

- (void)addCell
{
    NSIndexPath *indexPathToInsert = [NSIndexPath indexPathForItem:self.numberOfItems
                                                         inSection:0];
    self.numberOfItems++;
    [self.collectionView insertItemsAtIndexPaths:@[indexPathToInsert]];
    [self.collectionView scrollToItemAtIndexPath:indexPathToInsert
                                atScrollPosition:UICollectionViewScrollPositionCenteredVertically
                                        animated:YES];
}

If the above method is called to insert a cell while the inserted cell is outside the current visible bounds, the item is inserted without animation and the collection view scrolls to it, properly dequeuing and displaying cells on the way.

Problem occurs in iOS 7 & iOS 8 beta 5.

Stuart
  • 36,683
  • 19
  • 101
  • 139
  • For reference, there is [another question](http://stackoverflow.com/q/22389904/429427) which illustrates this issue when changing `contentOffset` inside `[UIView animateWithDuration:...]`. – Stuart Aug 29 '14 at 05:34
  • Can you dodge the issue by animating it to hidden whilst scrolling it off-screen (and the top row onto screen), then deleting the item when it's off screen? – pbasdf Aug 30 '14 at 22:32
  • @pbasdf Yes, that does work to ensure the scrolled-to cells appear properly. But suppose I delete cell `14` instead, while scrolled to the bottom - using your suggested workaround cell `14` would disappear and there would be a delay before the gap is filled. This is more problematic for my own collection view, where disappearing cells rely on the cell below to slide up over the top of it. I have to scroll first without fading/sliding the deleted cell, _then_ perform the delete. It looks quite odd, but it's an improvement, thanks. – Stuart Aug 31 '14 at 06:35
  • Yes, I can see that's not ideal. I have another dodge, which works for the simple case above, but a) I hate it, b) it might hit other problems in your real life case: Set the frame of your collection view to start above the top of the screen (at say origin.y=-100 with height increased by 100 accordingly. Then set the collection view header size to 100 (to ensure the first row of items is visible when the view first loads). When you scroll, the rows off the top of the visible screen are still in the collection view, and get animated down when you delete. – pbasdf Aug 31 '14 at 19:16
  • Oh, and c) it will only work if deleting less than one row's worth of items (though you could increase the offset if that's a problem) – pbasdf Aug 31 '14 at 19:23
  • Hmm thanks for the suggestion, but I think that is going to be a little too specific for a real use case (as you predicted). In my collection view, there is actually nothing stopping the user from selecting every cell and deleting them all simultaneously. Also, I think this idea has the potential to break other aspects of the collection, such as the animated transitions to/from the view. – Stuart Aug 31 '14 at 21:12
  • 2
    Have you tried with an **empty** `performBatchUpdates` immediately after your `deleteItemsAtIndexPaths`?? It seems to work for the simple case.... – pbasdf Sep 02 '14 at 13:40
  • @pbasdf Thanks, I appreciate your perseverance! At first I thought it was working, but there are couple of problems. It has the same effect as the `reloadData` workaround suggested in my question (turns out that the call to `reloadData` isn't necessary, just the batch update). Problem A: the effect of the batch update is to cause a layout pass for the scrolled-to rect prior to the scroll starting, so if you scroll more than a screenful of cells, you'll see a blank area as you scroll past it (this isn't really a big deal, as the delete won't scroll past non-deleted cells). – Stuart Sep 03 '14 at 10:55
  • 2
    Problem B: it doesn't resolve the underlying problem of layout attributes not being applied during the update. The most obvious impact of this would be on something like a sticky header - during the update, the header wouldn't remain 'stuck'! So in fact, any layout that relies on the current content offset (another example would be a cover flow layout) will still not respond well. Unfortunately, my layout(s) fall into this category (I'm not demanding at all, am I...). – Stuart Sep 03 '14 at 10:58
  • I was afraid the batch update might trigger the re-layout. I will give up at this point. I fear the crux of your problem may be insoluble: at the time the delete happens, the cells just off the top of the screen have been returned to the UICollectionView queue (so cellForItemAtIndexPath returns nil). You need either to override UICollectionView to force it to keep those cells, even though they're off screen, or to override deleteItems... to get it to allocate new cells to those items, before it animates the deletion. I can't work out how to do either, but I wish you luck! – pbasdf Sep 03 '14 at 11:38
  • I test, nothing happen with collectionView. You delete and insert cell outside performBatchUpdates, that's don't have animation. – LE SANG Sep 04 '14 at 03:21

1 Answers1

0

Adjust your content insets so that they go beyond the bounds of the device's screen size slightly.

collectionView.contentInsets = UIEdgeInsetsMake(-5,0,0,0); //Adjust this value until it looks ok