How to animate-in UICollectionView items
objc.io issue #12 is chock-full of awesomeness. Animations, especially interactive ones, are the heart and soul of delightful iOS 7 apps. I can’t recall when was the last time I read so many articles in such a short period of time.
One particular use case was not covered though and it just happened to be one I was tackling while building new version of Try Couch to 5k. In it, everything is collection views, often nested one inside another. What I wanted to do is sequentially animate each cell as it appears on the screen.
For example, as 5k plan has sequences of pace units, I wanted them to appear one by one, to draw runner’s eye to each one. At the same time, since lots of info is presented on the run-preview screen, I wanted to gradually load them to not overwhelm the runner with several parts of the screen animating at the same time (that would just be visual noise).
So, how to tackle this?
Easy approach would be just display cells as usual but hide them post-layout and then write UIVIew animate...
. However, this did not seem right.
First of all, I wanted to reuse existing UICollectionViewLayout
API, since it already has built-in support for animating items in:
(UICollectionViewLayoutAttributes *)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)indexPath
(UICollectionViewLayoutAttributes *)initialLayoutAttributesForAppearingSupplementaryElementOfKind:(NSString *)elementKind atIndexPath:(NSIndexPath *)indexPath
However, these methods are called only when adding items into existing collection view, while I wanted these to fire on initial build up. There’s an answer right there - initially load the collection view with 0 sections and items and then animate your insertItemsAtIndexPaths
and insertSections
.
First you need few counters for current section index and item index:
@interface SequenceVC () < UICollectionViewDataSource, UICollectionViewDelegate, SequenceLayoutDelegate >
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic) NSInteger currentSection;
@property (nonatomic) NSInteger currentItem;
@end
Remember that collection view dataSource’s numberOfSections
can’t return 0, it must be at least 1, thus I initialize the params like this:
self.currentSection = 0;
self.currentItem = -1;
In my use case, collection view sections are running sequences where each sequence can have variable number of pace units (jog, walk, sprint etc - these are items). So, this is how collection view data source looks like:
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView {
return MIN(self.currentSection+1, [self.delegate numberOfSequences]);
}
- (NSInteger)collectionView:(UICollectionView *)view numberOfItemsInSection:(NSInteger)section {
RDMSequence *seq = [self.delegate sequenceAtIndex:section];
if (section < self.currentSection)
return [seq.units count];
else
return MIN(self.currentItem+1, [seq.units count]);
}
It’s fairly easy to understand - if section is already displayed then second method returns all its items, while for section which is still animated-in return items up to current counter value.
Thus when you load this collection view, it will have one section with 0 items. Then you start the animation:
// animation starter!
- (void)display {
self.animationStartTime = CACurrentMediaTime();
if (!self.displayLink) {
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(animateSequence:)];
[self.displayLink addToRunLoop:NSRunLoop.mainRunLoop forMode:NSRunLoopCommonModes];
}
}
animationStartTime
is helper property, used to calculate time delta since start of animation.
animateSequence:
is the heart of the effect:
- (void)animateSequence:(CADisplayLink *)displayLink {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
CGFloat split = (displayLink.timestamp - self.animationStartTime);
// each item animation lasts _animationDuration
// if this was not reached yet, do nothing
if (split < self.animationCounter * self.animationDuration) {
return;
}
// ok, next item is up, so find out which one it is
NSInteger sectionCount = [self.delegate numberOfSequences];
RDMSequence *seq = [self.delegate sequenceAtIndex:self.currentSection];
NSInteger itemCount = [seq.units count];
self.animationCounter++;
// next item in section?
if (self.currentItem+1 < itemCount) {
self.currentItem++;
dispatch_async(dispatch_get_main_queue(), ^{
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:self.currentItem inSection:self.currentSection];
[self.collectionView insertItemsAtIndexPaths:@[indexPath]];
});
return;
}
// next section?
if (self.currentSection+1 < sectionCount) {
self.currentItem = -1;
self.currentSection++;
dispatch_async(dispatch_get_main_queue(), ^{
[self.collectionView insertSections:[NSIndexSet indexSetWithIndex:self.currentSection]];
});
return;
}
// cool, all done, clean out
[self.displayLink removeFromRunLoop:NSRunLoop.mainRunLoop forMode:NSRunLoopCommonModes];
self.displayLink = nil;
});
}
And that’s about it, final animation looks like this:
One problem with this is that your layout might potentially be computed many times, depending on the actual layout. In my case, I did use a custom layout (the part above is just a small bit in the larger layout) thus I opted to pre-compute the layout. In order to do that, I need to tell the layout the final counts for sections and items.
You may have notice in the @interface
code sample above the last protocol: SequenceLayoutDelegate
which does that:
- (NSInteger)numberOfSequencesForSequenceLayout:(RTPlanSequenceLayout *)layout {
return [self.delegate numberOfSequences];
}
- (NSInteger)sequenceLayout:(RTPlanSequenceLayout *)layout numberOfUnitsInSequenceAtPosition:(NSInteger)position {
RDMSequence *seq = [self.delegate sequenceAtIndex:position];
return [seq.units count];
}
- (NSInteger)sequenceLayout:(RTPlanSequenceLayout *)layout numberOfRepeatsForSequenceAtPosition:(NSInteger)position {
RDMSequence *seq = [self.delegate sequenceAtIndex:position];
return seq.repeatCountValue;
}
- (NSIndexPath *)maxIndexPathForSequenceLayout:(RTPlanSequenceLayout *)layout {
return [NSIndexPath indexPathForItem:self.currentItem inSection:self.currentSection];
}
The first 3 are used in prepareLayout
to pre-compute the entire layout, while the last method is used to limit the layoutAttributes returned, for example here:
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
if ([indexPath compare:[self.delegate maxIndexPathForSequenceLayout:self]] == NSOrderedDescending) return nil;
...
}
That’s all. I’m certain there are other ways to do this and would be happy to learn - please comment on the Twitter, I’m @radiantav there.