UICollectionView
和相关类的设置非常灵活和强大。但是灵活性一旦增强,某种程度上也增加了其复杂性: UICollectionView
比老式的 UITableView
更有深度,适用性也更强。
Collection View 深入太多了,事实上,Ole Begeman 和 Ash Furrow 之前曾在 objc.io 上发表过 自定义 Collection View 布局 和 UICollectionView + UIKit 力学,但是我依然有一些他们没有提及的内容可以写。在这篇文章中,我假设你已经非常熟悉 UICollectionView
的基本布局,并且至少阅读了苹果精彩的编程指南以及 Ole 之前的文章。
本文的第一部分将集中讨论并举例说明如何用不同的类和方法来共同帮助实现一些常见的 UICollectionView
动画。在第二部分,我们将看一下带有 collection views 的 view controller 转场动画以及在 useLayoutToLayoutNavigationTransitions
可用时使用其进行转场,如果不可用时,我们会实现一个自定义转场动画。
你可以在 GitHub 中找到本文提到的两个示例工程:
Collection View 布局动画
标准 UICollectionViewFlowLayout
除了动画是非常容易自定义的,苹果选择了一种安全的途径去实现一个简单的淡入淡出动画作为所有布局的默认动画。如果你想实现自定义动画,最好的办法是子类化 UICollectionViewFlowLayout
并且在适当的地方实现你的动画。让我们通过一些例子来了解 UICollectionViewFlowLayout
子类中的一些方法如何协助完成自定义动画。
插入删除元素
一般来说,我们对布局属性从初始状态到结束状态进行线性插值来计算 collection view 的动画参数。然而,新插入或者删除的元素并没有最初或最终状态来进行插值。要计算这样的 cells 的动画,collection view 将通过 initialLayoutAttributesForAppearingItemAtIndexPath:
以及 finalLayoutAttributesForAppearingItemAtIndexPath:
方法来询问其布局对象,以获取最初的和最后的属性。苹果默认的实现中,对于特定的某个 indexPath,返回的是它的通常的位置,但 alpha
值为 0.0,这就产生了一个淡入或淡出动画。如果你想要更漂亮的效果,比如你的新的 cells 从屏幕底部发射并且旋转飞到对应位置,你可以如下实现这样的布局子类:
1 2 3 4 5 6 7 8 9 |
- (UICollectionViewLayoutAttributes*)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)itemIndexPath { UICollectionViewLayoutAttributes *attr = [self layoutAttributesForItemAtIndexPath:itemIndexPath]; attr.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(0.2, 0.2), M_PI); attr.center = CGPointMake(CGRectGetMidX(self.collectionView.bounds), CGRectGetMaxY(self.collectionView.bounds)); return attr; } |
结果如下:
对应的 finalLayoutAttributesForAppearingItemAtIndexPath:
方法中,除了设定了不同的 transform 以外,其他都很相似。
响应设备旋转
设备方向变化通常会导致 collection view 的 bounds 变化。如果通过 shouldInvalidateLayoutForBoundsChange:
判定为布局需要被无效化并重新计算的时候,布局对象会被询问以提供新的布局。UICollectionViewFlowLayout
的默认实现正确地处理了这个情况,但是如果你子类化 UICollectionViewLayout
的话,你需要在边界变化时返回 YES
:
1 2 3 4 5 6 7 8 |
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { CGRect oldBounds = self.collectionView.bounds; if (!CGSizeEqualToSize(oldBounds.size, newBounds.size)) { return YES; } return NO; } |
在 bounds 变化的动画中,collection view 表现得像当前显示的元素被移除然后又在新的 bounds 中被被重新插入,这会对每个 IndexPath 产生一系列的 finalLayoutAttributesForAppearingItemAtIndexPath:
和 initialLayoutAttributesForAppearingItemAtIndexPath:
的调用。
如果你在插入和删除的时候加入了非常炫的动画,现在你应该看看为何苹果明智的使用简单的淡入淡出动画作为默认效果:
啊哦…
为了防止这种不想要的动画,初始化位置 -> 删除动画 -> 插入动画 -> 最终位置的顺序必须完全匹配 collection view 的每一项,以便最终呈现出一个平滑动画。换句话说,finalLayoutAttributesForAppearingItemAtIndexPath:
以及 initialLayoutAttributesForAppearingItemAtIndexPath:
应该针对元素到底是真的在显示或者消失,还是 collection view 正在经历的边界改变动画的不同情况,做出不同反应,并返回不同的布局属性。
幸运的是,collection view 会告知布局对象哪一种动画将被执行。它分别通过调用 prepareForAnimatedBoundsChange:
和 prepareForCollectionViewUpdates:
来对应 bounds 变化以及元素更新。出于本实例的说明目的,我们可以使用 prepareForCollectionViewUpdates:
来跟踪更新对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems { [super prepareForCollectionViewUpdates:updateItems]; NSMutableArray *indexPaths = [NSMutableArray array]; for (UICollectionViewUpdateItem *updateItem in updateItems) { switch (updateItem.updateAction) { case UICollectionUpdateActionInsert: [indexPaths addObject:updateItem.indexPathAfterUpdate]; break; case UICollectionUpdateActionDelete: [indexPaths addObject:updateItem.indexPathBeforeUpdate]; break; case UICollectionUpdateActionMove: [indexPaths addObject:updateItem.indexPathBeforeUpdate]; [indexPaths addObject:updateItem.indexPathAfterUpdate]; break; default: NSLog(@"unhandled case: %@", updateItem); break; } } self.indexPathsToAnimate = indexPaths; } |
以及修改我们元素的插入动画,让元素只在其正在被插入 collection view 时进行发射:
1 2 3 4 5 6 7 8 9 10 11 12 |
- (UICollectionViewLayoutAttributes*)initialLayoutAttributesForAppearingItemAtIndexPath:(NSIndexPath *)itemIndexPath { UICollectionViewLayoutAttributes *attr = [self layoutAttributesForItemAtIndexPath:itemIndexPath]; if ([_indexPathsToAnimate containsObject:itemIndexPath]) { attr.transform = CGAffineTransformRotate(CGAffineTransformMakeScale(0.2, 0.2), M_PI); attr.center = CGPointMake(CGRectGetMidX(self.collectionView.bounds), CGRectGetMaxY(self.collectionView.bounds)); [_indexPathsToAnimate removeObject:itemIndexPath]; } return attr; } |
如果这个元素没有正在被插入,那么将通过 layoutAttributesForItemAtIndexPath
来返回一个普通的属性,以此取消特殊的外观动画。结合 finalLayoutAttributesForAppearingItemAtIndexPath:
中相应的逻辑,最终将会使元素能够在 bounds 变化时,从初始位置到最终位置以很流畅的动画形式实现,从而建立一个简单但很酷的动画效果:
交互式布局动画
Collection views 让用户通过手势实现与布局交互这件事变得很容易。如苹果建议的那样,为 collection view 布局添加交互的途径一般会遵循以下步骤:
- 创建手势识别
- 将手势识别添加给 collection view
- 通过手势来驱动布局动画
让我们来看看我们如何可以建立一些用户可缩放捏合的元素,以及一旦用户释放他们的捏合手势元素返回到原始大小。
我们的处理方式可能会是这样:
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 27 28 29 30 31 32 33 |
- (void)handlePinch:(UIPinchGestureRecognizer *)sender { if ([sender numberOfTouches] != 2) return; if (sender.state == UIGestureRecognizerStateBegan || sender.state == UIGestureRecognizerStateChanged) { // 获取捏合的点 CGPoint p1 = [sender locationOfTouch:0 inView:[self collectionView]]; CGPoint p2 = [sender locationOfTouch:1 inView:[self collectionView]]; // 计算扩展距离 CGFloat xd = p1.x - p2.x; CGFloat yd = p1.y - p2.y; CGFloat distance = sqrt(xd*xd + yd*yd); // 更新自定义布局参数以及无效化 FJAnimatedFlowLayout* layout = (FJAnimatedFlowLayout*)[[self collectionView] collectionViewLayout]; NSIndexPath *pinchedItem = [self.collectionView indexPathForItemAtPoint:CGPointMake(0.5*(p1.x+p2.x), 0.5*(p1.y+p2.y))]; [layout resizeItemAtIndexPath:pinchedItem withPinchDistance:distance]; [layout invalidateLayout]; } else if (sender.state == UIGestureRecognizerStateCancelled || sender.state == UIGestureRecognizerStateEnded){ FJAnimatedFlowLayout* layout = (FJAnimatedFlowLayout*)[[self collectionView] collectionViewLayout]; [self.collectionView performBatchUpdates:^{ [layout resetPinchedItem]; } completion:nil]; } } |
这个捏合操作需要计算捏合距离并找出被捏合的元素,并且在用户捏合的时候通知布局以实现自身更新。当捏合手势结束的时候,布局会做一个批量更新动画返回原始尺寸。
另一方面,我们的布局始终在跟踪捏合的元素以及期望尺寸,并在需要的时候提供正确的属性:
1 2 3 4 5 6 7 8 9 10 11 12 |
- (NSArray*)layoutAttributesForElementsInRect:(CGRect)rect { NSArray *attrs = [super layoutAttributesForElementsInRect:rect]; if (_pinchedItem) { UICollectionViewLayoutAttributes *attr = [[attrs filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"indexPath == %@", _pinchedItem]] firstObject]; attr.s |