19

I want to change the frame size of a UICollectionView in an animation that runs alongside an animated cell insertion to the same collection view inside a performBatchUpdates:completion: block.

This is the code that triggers the cell insertion:

[collectionView performBatchUpdates:^{
    indexPathOfAddedCell = ...;
    [collectionView insertItemsAtIndexPaths:@[ indexPathOfAddedCell ]];
} completion:nil];

Because the cell insertion causes the collection view's contentSize to change, I tried KVO-registering for changes to that property and then trigger the collection view frame update from the KVO handler.

The problem with that approach is that the KVO trigger for contentSize fires too late: the cell insertion animation has already completed at that time (actually, KVO triggers right before the completion handler of performBatchUpdates:completion: gets called but after the animation has played out in the UI).

Iʼm not using auto layout.

Edit: I put a sample project to demonstrate my problem on GitHub.

Edit 2: I should mention that I need this for a component Iʼm writing (OLEContainerScrollView) that is supposed to be 100% independent of the collection view. Because of this, I cannot subclass the collection view layout, nor do I have influence over the code that triggers the cell animations. Ideally, a solution would also work for UITableView, which exhibits the same behavior.

Ole Begemann
  • 135,006
  • 31
  • 278
  • 256

3 Answers3

10

I looked at how both collection views and table views update their content inset, and indeed, the scroll view's content size is only updated after the animation completes in both cases. There doesn't seem to be a really good method of listening to the future content size without using private API, but it is possible.

For table views, the trigger for animation start is -[UITableView _endCellAnimationsWithContext:]. This method sets up all the animations needed (for the future visible cells only), executes them and sets a completion block which eventually calls -[UITableView _updateContentSize]. _updateContentSize uses the internal -[UITableView _contentSize] method to set the correct scroll view content size. Since _endCellAnimationsWithContext: deals with animations only, the data behind the table view is already updated, so calling _contentSize (or using valueForKey:@"_contentSize") returns a correct size.

It is very similar for collection views. The trigger is -[UICollectionView _endItemAnimations], it starts many animations for each cell, header and footer, and when all the animations finish, -[UICollectionView _updateAnimationDidStop:finished:context:] sets the correct content size. Because this is a collection view, its collection view layout actually knows the target content size, so you can call -[UICollectionViewLayout collectionViewContentSize] to get the updated content size.

None of these are really good options for use on the app store. One option I can think of is ISA-swizzling each scroll view subclass added, and wrapping all animatable entry points, tracking whether they are batched or not, and either at the end of the batch or at the end of the standalone animated operation, use the corresponding methods (-[UITableView _contentSize] and -[UICollectionViewLayout collectionViewContentSize]) to get the target content size.


Original answer, in case you want to hear about changes to a collection view size in your own collection views:

Subclass the collection view layout (if you haven't already), and using either notification center or a delegate method, notify on prepareForAnimatedBoundsChange: for other animations. They will be added to the animation block.

From the documentation:

You can also use this method to perform additional animations. Any animations you create are added to the animation block used to handle the insertions, deletions, and bounds changes.

You may need to determine what the changes are, and only notify about insertion animations.

Léo Natan
  • 56,823
  • 9
  • 150
  • 195
  • Hi Ole, I will take a look soon. – Léo Natan Jun 29 '14 at 06:29
  • As Ole Begemann said, the solution should work with UITableView too, so `prepareForAnimatedBoundsChange:` will work only for UICollectionView – arturdev Jul 04 '14 at 07:06
  • @arturdev I am looking for a general solution. – Léo Natan Jul 04 '14 at 09:02
  • @LeoNatan Wow, thanks a lot for your thorough investigation. I will experiment with this and report my results (time permitting, it could take a while). – Ole Begemann Jul 04 '14 at 10:41
  • @LeoNatan: first results are promising, but I havenʼt got it to work yet. I swizzled [UICollectionView _endItemAnimations] and it does indeed get called at the right time. However, the layoutʼs `collectionViewContentSize` has not been updated to the new value yet, so I don't seem to be able to use it to drive the animation. I'll continue experimenting. – Ole Begemann Jul 08 '14 at 13:45
  • @Ole I will also look. For me it was the correct value. – Léo Natan Jul 08 '14 at 13:55
  • @LeoNatan: Ugh, my mistake, sorry. I was calling the original IMP too late in my swizzled method. I've got it working now. – Ole Begemann Jul 08 '14 at 14:32
  • @Ole just need to mask the swizzle to pass AppStore now. Not really that hard. – Léo Natan Jul 08 '14 at 14:35
7

I've looked into your demo project and I think that there is no need of KVO. If you want to change collection view's frame with animation while inserting new cell, then I think you can do something like this:

#import "ViewController.h"

@interface ViewController () <UICollectionViewDataSource, UICollectionViewDelegate>

@property (weak, nonatomic) IBOutlet UICollectionView *collectionView;
@property (weak, nonatomic) IBOutlet UIView *otherView;
@property (nonatomic) NSInteger numberOfItemsInCollectionView;

@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    self.numberOfItemsInCollectionView = 1; // This is our model
}

- (IBAction)addItem:(id)sender
{
    // Change the model
    self.numberOfItemsInCollectionView += 1;

    [UIView animateWithDuration:0.24 animations:^{
        NSIndexPath *indexPathOfInsertedCell = [NSIndexPath indexPathForItem:self.numberOfItemsInCollectionView - 1 inSection:0];
        [self.collectionView insertItemsAtIndexPaths:@[ indexPathOfInsertedCell ]];

        CGRect collectionViewFrame = self.collectionView.frame;
        collectionViewFrame.size.height = (self.numberOfItemsInCollectionView * 40) + 94;
        self.collectionView.frame = collectionViewFrame;
    }];

}

#pragma mark - UICollectionViewDataSource

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

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

@end

Or if you want fade effect:

- (IBAction)addItem:(id)sender
{
    // Change the model
    self.numberOfItemsInCollectionView += 1;

    CATransition *transition = [CATransition animation];
    transition.type = kCATransitionFade;
    transition.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    transition.fillMode = kCAFillModeForwards;
    transition.duration = 0.5;

    [[self.collectionView layer] addAnimation:transition forKey:@"UICollectionViewInsertRowAnimationKey"];
    NSIndexPath *indexPathOfInsertedCell = [NSIndexPath indexPathForItem:self.numberOfItemsInCollectionView - 1 inSection:0];
    [self.collectionView insertItemsAtIndexPaths:@[ indexPathOfInsertedCell ]];

    CGRect collectionViewFrame = self.collectionView.frame;
    collectionViewFrame.size.height = (self.numberOfItemsInCollectionView * 40) + 94;
    self.collectionView.frame = collectionViewFrame;
}

I've checked this with your demo project, and its works for me. Please try this and comment if it works for you too.

arturdev
  • 10,884
  • 2
  • 39
  • 67
  • Thanks for the suggestion, but as I said, I have no control over the code that triggers the cell animations. Therefore, I cannot wrap it in an animation block. – Ole Begemann Jul 03 '14 at 10:51
  • Can you please explain? You said that you have no control, but what is ` [collectionView insertItemsAtIndexPaths:@[ indexPathOfAddedCell ]];` in that case? ` – arturdev Jul 03 '14 at 13:11
  • I'm not writing that code. As mentioned, I'm writing a component that needs to react to changes in the collection view. – Ole Begemann Jul 03 '14 at 17:47
-1

Why not simply adding an animation block for changing the collection view's frame?
This produces an animated bounds change that goes well together with the insertion fade. The KVO stuff is not necessary for this solution.

EDIT: You could trigger that code when you model changes

Here's the relevant code for your sample project:

- (IBAction)addItem:(id)sender
{
    // Change the model
    self.numberOfItemsInCollectionView += 1;

    // Animate cell insertion
    [self.collectionView performBatchUpdates:^{
        NSIndexPath *indexPathOfInsertedCell = [NSIndexPath indexPathForItem:self.numberOfItemsInCollectionView - 1 inSection:0];
        [self.collectionView insertItemsAtIndexPaths:@[ indexPathOfInsertedCell ]];


    } completion:nil];

    // animate the collection view's frame
    [UIView animateWithDuration:.5
                     animations:^{
                         CGRect collectionViewFrame = self.collectionView.frame;
                         collectionViewFrame.size.height = (self.numberOfItemsInCollectionView * 40) + 94;
                         self.collectionView.frame = collectionViewFrame;
                     }];
}
de.
  • 7,068
  • 3
  • 40
  • 69