UICollectionView
和相關(guān)類的設(shè)置非常靈活和強(qiáng)大。但是靈活性一旦增強(qiáng),某種程度上也增加了其復(fù)雜性: UICollectionView
比老式的 UITableView
更有深度,適用性也更強(qiáng)。
Collection View 深入太多了,事實(shí)上,Ole Begeman 和 Ash Furrow 之前曾在 objc.io 上發(fā)表過(guò) 自定義 Collection View 布局 和 UICollectionView + UIKit 力學(xué),但是我依然有一些他們沒有提及的內(nèi)容可以寫。在這篇文章中,我假設(shè)你已經(jīng)非常熟悉 UICollectionView
的基本布局,并且至少閱讀了蘋果精彩的編程指南以及 Ole 之前的文章。
本文的第一部分將集中討論并舉例說(shuō)明如何用不同的類和方法來(lái)共同幫助實(shí)現(xiàn)一些常見的 UICollectionView
動(dòng)畫。在第二部分,我們將看一下帶有 collection views 的 view controller 轉(zhuǎn)場(chǎng)動(dòng)畫以及在 useLayoutToLayoutNavigationTransitions
可用時(shí)使用其進(jìn)行轉(zhuǎn)場(chǎng),如果不可用時(shí),我們會(huì)實(shí)現(xiàn)一個(gè)自定義轉(zhuǎn)場(chǎng)動(dòng)畫。
你可以在 GitHub 中找到本文提到的兩個(gè)示例工程:
標(biāo)準(zhǔn) UICollectionViewFlowLayout
除了動(dòng)畫是非常容易自定義的,蘋果選擇了一種安全的途徑去實(shí)現(xiàn)一個(gè)簡(jiǎn)單的淡入淡出動(dòng)畫作為所有布局的默認(rèn)動(dòng)畫。如果你想實(shí)現(xiàn)自定義動(dòng)畫,最好的辦法是子類化 UICollectionViewFlowLayout
并且在適當(dāng)?shù)牡胤綄?shí)現(xiàn)你的動(dòng)畫。讓我們通過(guò)一些例子來(lái)了解 UICollectionViewFlowLayout
子類中的一些方法如何協(xié)助完成自定義動(dòng)畫。
一般來(lái)說(shuō),我們對(duì)布局屬性從初始狀態(tài)到結(jié)束狀態(tài)進(jìn)行線性插值來(lái)計(jì)算 collection view 的動(dòng)畫參數(shù)。然而,新插入或者刪除的元素并沒有最初或最終狀態(tài)來(lái)進(jìn)行插值。要計(jì)算這樣的 cells 的動(dòng)畫,collection view 將通過(guò) initialLayoutAttributesForAppearingItemAtIndexPath:
以及 finalLayoutAttributesForAppearingItemAtIndexPath:
方法來(lái)詢問(wèn)其布局對(duì)象,以獲取最初的和最后的屬性。蘋果默認(rèn)的實(shí)現(xiàn)中,對(duì)于特定的某個(gè) indexPath,返回的是它的通常的位置,但 alpha
值為 0.0,這就產(chǎn)生了一個(gè)淡入或淡出動(dòng)畫。如果你想要更漂亮的效果,比如你的新的 cells 從屏幕底部發(fā)射并且旋轉(zhuǎn)飛到對(duì)應(yīng)位置,你可以如下實(shí)現(xiàn)這樣的布局子類:
- (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;
}
結(jié)果如下:
http://wiki.jikexueyuan.com/project/objc/images/12-24.gif" alt="" />
對(duì)應(yīng)的 finalLayoutAttributesForAppearingItemAtIndexPath:
方法中,除了設(shè)定了不同的 transform 以外,其他都很相似。
設(shè)備方向變化通常會(huì)導(dǎo)致 collection view 的 bounds 變化。如果通過(guò) shouldInvalidateLayoutForBoundsChange:
判定為布局需要被無(wú)效化并重新計(jì)算的時(shí)候,布局對(duì)象會(huì)被詢問(wèn)以提供新的布局。UICollectionViewFlowLayout
的默認(rèn)實(shí)現(xiàn)正確地處理了這個(gè)情況,但是如果你子類化 UICollectionViewLayout
的話,你需要在邊界變化時(shí)返回 YES
:
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
CGRect oldBounds = self.collectionView.bounds;
if (!CGSizeEqualToSize(oldBounds.size, newBounds.size)) {
return YES;
}
return NO;
}
在 bounds 變化的動(dòng)畫中,collection view 表現(xiàn)得像當(dāng)前顯示的元素被移除然后又在新的 bounds 中被被重新插入,這會(huì)對(duì)每個(gè) IndexPath 產(chǎn)生一系列的 finalLayoutAttributesForAppearingItemAtIndexPath:
和 initialLayoutAttributesForAppearingItemAtIndexPath:
的調(diào)用。
如果你在插入和刪除的時(shí)候加入了非常炫的動(dòng)畫,現(xiàn)在你應(yīng)該看看為何蘋果明智的使用簡(jiǎn)單的淡入淡出動(dòng)畫作為默認(rèn)效果:
http://wiki.jikexueyuan.com/project/objc/images/12-25.gif" alt="" />
啊哦...
為了防止這種不想要的動(dòng)畫,初始化位置 -> 刪除動(dòng)畫 -> 插入動(dòng)畫 -> 最終位置的順序必須完全匹配 collection view 的每一項(xiàng),以便最終呈現(xiàn)出一個(gè)平滑動(dòng)畫。換句話說(shuō),finalLayoutAttributesForAppearingItemAtIndexPath:
以及 initialLayoutAttributesForAppearingItemAtIndexPath:
應(yīng)該針對(duì)元素到底是真的在顯示或者消失,還是 collection view 正在經(jīng)歷的邊界改變動(dòng)畫的不同情況,做出不同反應(yīng),并返回不同的布局屬性。
幸運(yùn)的是,collection view 會(huì)告知布局對(duì)象哪一種動(dòng)畫將被執(zhí)行。它分別通過(guò)調(diào)用 prepareForAnimatedBoundsChange:
和 prepareForCollectionViewUpdates:
來(lái)對(duì)應(yīng) bounds 變化以及元素更新。出于本實(shí)例的說(shuō)明目的,我們可以使用 prepareForCollectionViewUpdates:
來(lái)跟蹤更新對(duì)象:
- (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;
}
以及修改我們?cè)氐牟迦雱?dòng)畫,讓元素只在其正在被插入 collection view 時(shí)進(jìn)行發(fā)射:
- (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;
}
如果這個(gè)元素沒有正在被插入,那么將通過(guò) layoutAttributesForItemAtIndexPath
來(lái)返回一個(gè)普通的屬性,以此取消特殊的外觀動(dòng)畫。結(jié)合 finalLayoutAttributesForAppearingItemAtIndexPath:
中相應(yīng)的邏輯,最終將會(huì)使元素能夠在 bounds 變化時(shí),從初始位置到最終位置以很流暢的動(dòng)畫形式實(shí)現(xiàn),從而建立一個(gè)簡(jiǎn)單但很酷的動(dòng)畫效果:
http://wiki.jikexueyuan.com/project/objc/images/12-26.gif" alt="" />
Collection views 讓用戶通過(guò)手勢(shì)實(shí)現(xiàn)與布局交互這件事變得很容易。如蘋果建議的那樣,為 collection view 布局添加交互的途徑一般會(huì)遵循以下步驟:
讓我們來(lái)看看我們?nèi)绾慰梢越⒁恍┯脩艨煽s放捏合的元素,以及一旦用戶釋放他們的捏合手勢(shì)元素返回到原始大小。
我們的處理方式可能會(huì)是這樣:
- (void)handlePinch:(UIPinchGestureRecognizer *)sender {
if ([sender numberOfTouches] != 2)
return;
if (sender.state == UIGestureRecognizerStateBegan ||
sender.state == UIGestureRecognizerStateChanged) {
// 獲取捏合的點(diǎn)
CGPoint p1 = [sender locationOfTouch:0 inView:[self collectionView]];
CGPoint p2 = [sender locationOfTouch:1 inView:[self collectionView]];
// 計(jì)算擴(kuò)展距離
CGFloat xd = p1.x - p2.x;
CGFloat yd = p1.y - p2.y;
CGFloat distance = sqrt(xd*xd + yd*yd);
// 更新自定義布局參數(shù)以及無(wú)效化
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];
}
}
這個(gè)捏合操作需要計(jì)算捏合距離并找出被捏合的元素,并且在用戶捏合的時(shí)候通知布局以實(shí)現(xiàn)自身更新。當(dāng)捏合手勢(shì)結(jié)束的時(shí)候,布局會(huì)做一個(gè)批量更新動(dòng)畫返回原始尺寸。
另一方面,我們的布局始終在跟蹤捏合的元素以及期望尺寸,并在需要的時(shí)候提供正確的屬性:
- (NSArray*)layoutAttributesForElementsInRect:(CGRect)rect
{
NSArray *attrs = [super layoutAttributesForElementsInRect:rect];
if (_pinchedItem) {
UICollectionViewLayoutAttributes *attr = [[attrs filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"indexPath == %@", _pinchedItem]] firstObject];
attr.size = _pinchedItemSize;
attr.zIndex = 100;
}
return attrs;
}
我們通過(guò)一些例子來(lái)說(shuō)明了如何在 collection view 布局中創(chuàng)建自定義動(dòng)畫。雖然 UICollectionViewFlowLayout
并不直接允許定制動(dòng)畫,但是蘋果工程師提供了清晰的架構(gòu)讓你可以子類化并實(shí)現(xiàn)各種自定義行為。從本質(zhì)來(lái)說(shuō),在你的 UICollectionViewLayout
子類中正確地響應(yīng)以下信號(hào),并對(duì)那些要求返回 UICollectionViewLayoutAttributes
的方法返回合適的屬性,那么實(shí)現(xiàn)自定義布局和動(dòng)畫的唯一約束就是你的想象力:
prepareLayout
prepareForCollectionViewUpdates:
finalizeCollectionViewUpdates
prepareForAnimatedBoundsChange:
finalizeAnimatedBoundsChange
shouldInvalidateLayoutForBoundsChange:
更引人入勝的動(dòng)畫可以結(jié)合像在 objc.io 話題 #5 中 UIKit 力學(xué)這樣的技術(shù)來(lái)實(shí)現(xiàn)。
就如 Chris 之前在 objc.io 的文章中所說(shuō)的那樣,iOS 7 中的一個(gè)重大更新是自定義 view controller 轉(zhuǎn)場(chǎng)動(dòng)畫。與自定義轉(zhuǎn)場(chǎng)動(dòng)畫相呼應(yīng),蘋果也在 UICollectionViewController
添加了 useLayoutToLayoutNavigationTransitions
標(biāo)記來(lái)在可復(fù)用的單個(gè) collection view 間啟用導(dǎo)航轉(zhuǎn)場(chǎng)。蘋果自己的照片和日歷應(yīng)用就是這類轉(zhuǎn)場(chǎng)動(dòng)畫的非常好的代表作。
讓我們來(lái)看看我們?nèi)绾文軌蚶蒙弦还?jié)相同的示例項(xiàng)目達(dá)到類似的效果:
http://wiki.jikexueyuan.com/project/objc/images/12-27.gif" alt="" />
為了使布局到布局的轉(zhuǎn)場(chǎng)動(dòng)畫工作,navigation controller 的 root view controller 必須是一個(gè) useLayoutToLayoutNavigationTransitions
設(shè)置為 NO
的 collection view controller。當(dāng)另一個(gè) useLayoutToLayoutNavigationTransitions
設(shè)置為 YES
的 UICollectionViewController
實(shí)例被 push 到根視圖控制器之上時(shí),navigation controller 會(huì)用布局轉(zhuǎn)場(chǎng)動(dòng)畫來(lái)代替標(biāo)準(zhǔn)的 push 轉(zhuǎn)場(chǎng)動(dòng)畫。這里要注意一個(gè)重要的細(xì)節(jié),根視圖控制器的 collection view 實(shí)例被回收用于在導(dǎo)航棧上 push 進(jìn)來(lái)的 collection 控制器中,如果你試圖在 viewDidLoad
之類的方法中中設(shè)置 collection view 屬性, 它們將不會(huì)有任何反應(yīng),你也不會(huì)收到任何警告。
這個(gè)行為可能最常見的陷阱是期望回收的 collection view 根據(jù)頂層的 collection 視圖控制器來(lái)更新數(shù)據(jù)源和委托。它當(dāng)然不會(huì)這樣:根 collection 視圖控制器會(huì)保持?jǐn)?shù)據(jù)源和委托,除非我們做點(diǎn)什么。
解決此問(wèn)題的方法是實(shí)現(xiàn) navigation controller 的委托方法,并根據(jù)導(dǎo)航堆棧頂部的當(dāng)前視圖控制器的需要正確設(shè)置 collection view 的數(shù)據(jù)源和委托。在我們簡(jiǎn)單的例子中,這可以通過(guò)以下方式實(shí)現(xiàn):
- (void)navigationController:(UINavigationController *)navigationController didShowViewController:(UIViewController *)viewController animated:(BOOL)animated
{
if ([viewController isKindOfClass:[FJDetailViewController class]]) {
FJDetailViewController *dvc = (FJDetailViewController*)viewController;
dvc.collectionView.dataSource = dvc;
dvc.collectionView.delegate = dvc;
[dvc.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:_selectedItem inSection:0] atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:NO];
}
else if (viewController == self){
self.collectionView.dataSource = self;
self.collectionView.delegate = self;
}
}
當(dāng)詳細(xì)頁(yè)面的 collection view 被推入導(dǎo)航棧時(shí),我們重新設(shè)置 collection view 的數(shù)據(jù)源到詳細(xì)視圖控制器,確保只有被選擇的 cell 顏色顯示在詳細(xì)頁(yè)面的 collection view 中。如果我們不打算這樣做,布局依然可以正確過(guò)渡,但是collection 將顯示所有的 cells。在實(shí)際應(yīng)用中,detail 的數(shù)據(jù)源通常負(fù)責(zé)在轉(zhuǎn)場(chǎng)動(dòng)畫過(guò)程中顯示更詳細(xì)的數(shù)據(jù)。
使用了 useLayoutToLayoutNavigationTransitions
的布局和布局間導(dǎo)航轉(zhuǎn)換是很有用的,但卻局限于僅在 兩個(gè) view controller 都是 UICollectionViewController
的實(shí)例,并且轉(zhuǎn)場(chǎng)的必須發(fā)生在頂級(jí) collection views 之間。為了達(dá)到在任意視圖控制器的任意 collection view 之間都能實(shí)現(xiàn)相似的過(guò)渡,我們需要自定義一個(gè) view collection 的轉(zhuǎn)場(chǎng)動(dòng)畫。
http://wiki.jikexueyuan.com/project/objc/images/12-28.gif" alt="" />
針對(duì)此類自定義過(guò)渡的動(dòng)畫控制器,需要遵循以下步驟進(jìn)行設(shè)計(jì):
一個(gè)這樣的動(dòng)畫設(shè)計(jì)有兩重缺陷:它只能對(duì)初始的 collection view 的可見元素制作動(dòng)畫,因?yàn)?a rel="nofollow" >快照 APIs 只能工作于屏幕上可見的 view,另外,依賴于可見的元素?cái)?shù)量,可能會(huì)有很多的 views 需要進(jìn)行正確的跟蹤并為其制作動(dòng)畫。但另一方面,這種設(shè)計(jì)又具有一個(gè)明顯的優(yōu)勢(shì),那就是它可以為所有類型的 UICollectionViewLayout
組合所使用。這樣一個(gè)系統(tǒng)的實(shí)現(xiàn)就留給讀者們?nèi)ミM(jìn)行練習(xí)吧。
在附帶的演示項(xiàng)目中我們用另一種途徑進(jìn)行了實(shí)現(xiàn),它依賴于一些 UICollectionViewFlowLayout
的巧合。
基本的想法是,因?yàn)樵?collection view 和目標(biāo) collection view 都擁有有效的 flow layouts,因此源 layout 的布局屬性正好可以用作目標(biāo) collection view 的布局中的初始布局屬性,以此驅(qū)動(dòng)轉(zhuǎn)場(chǎng)動(dòng)畫。一旦正確建立,就算對(duì)于那些一開始在屏幕上不可見的元素,collection view 的機(jī)制都將為我們追蹤它們并進(jìn)行動(dòng)畫。下面是我們的動(dòng)畫控制器中的 animateTransition:
的核心代碼:
CGRect initialRect = [inView.window convertRect:_fromCollectionView.frame fromView:_fromCollectionView.superview];
CGRect finalRect = [transitionContext finalFrameForViewController:toVC];
UICollectionViewFlowLayout *toLayout = (UICollectionViewFlowLayout*) _toCollectionView.collectionViewLayout;
UICollectionViewFlowLayout *currentLayout = (UICollectionViewFlowLayout*) _fromCollectionView.collectionViewLayout;
//制作原來(lái)布局的拷貝
UICollectionViewFlowLayout *currentLayoutCopy = [[UICollectionViewFlowLayout alloc] init];
currentLayoutCopy.itemSize = currentLayout.itemSize;
currentLayoutCopy.sectionInset = currentLayout.sectionInset;
currentLayoutCopy.minimumLineSpacing = currentLayout.minimumLineSpacing;
currentLayoutCopy.minimumInteritemSpacing = currentLayout.minimumInteritemSpacing;
currentLayoutCopy.scrollDirection = currentLayout.scrollDirection;
//將拷貝賦值給源 collection view
[self.fromCollectionView setCollectionViewLayout:currentLayoutCopy animated:NO];
UIEdgeInsets contentInset = _toCollectionView.contentInset;
CGFloat oldBottomInset = contentInset.bottom;
//強(qiáng)制在目標(biāo) collection view 中設(shè)定一個(gè)很大的 bottom inset
contentInset.bottom = CGRectGetHeight(finalRect)-(toLayout.itemSize.height+toLayout.sectionInset.bottom+toLayout.sectionInset.top);
self.toCollectionView.contentInset = contentInset;
//將源布局設(shè)置給目標(biāo) collection view
[self.toCollectionView setCollectionViewLayout:currentLayout animated:NO];
toView.frame = initialRect;
[inView insertSubview:toView aboveSubview:fromView];
[UIView
animateWithDuration:[self transitionDuration:transitionContext]
delay:0
options:UIViewAnimationOptionBeginFromCurrentState
animations:^{
//使用最終 frame 制作動(dòng)畫
toView.frame = finalRect;
//在 performUpdates 中設(shè)定最終的布局
[_toCollectionView
performBatchUpdates:^{
[_toCollectionView setCollectionViewLayout:toLayout animated:NO];
}
completion:^(BOOL finished) {
_toCollectionView.contentInset = UIEdgeInsetsMake(contentInset.top,
contentInset.left,
oldBottomInset,
contentInset.right);
}];
} completion:^(BOOL finished) {
[transitionContext completeTransition:YES];
}];
首先,動(dòng)畫控制器確保目標(biāo) collection view 以與原來(lái)的 collection view 完全相同的框架和布局作為開始。接著,它將源 collection view 的布局設(shè)定給目標(biāo) collection view,以確保其不會(huì)失效。與此同時(shí),該布局已經(jīng)復(fù)制到另一個(gè)新的布局對(duì)象中,而這個(gè)布局對(duì)象則是為防止在導(dǎo)航回原始視圖控制器時(shí)出現(xiàn)奇怪的布局 bug。我們還會(huì)強(qiáng)制在目標(biāo) collection view 的底部設(shè)定一個(gè)很大的 content inset,來(lái)確保布局在動(dòng)畫的初始位置時(shí)保持在一行上。觀察日志的話,你會(huì)發(fā)現(xiàn)由于元素的尺寸加上 inset 的尺寸會(huì)比 collection view 的非滾動(dòng)維度要大,因此 collection view 會(huì)在控制臺(tái)警告。在這樣的情況下,collection view 的行為是沒有定義的,我們也只是使用這樣一個(gè)不穩(wěn)定的狀態(tài)來(lái)作為我們轉(zhuǎn)換動(dòng)畫的初始狀態(tài)。最后,復(fù)雜的動(dòng)畫 block 將展現(xiàn)它的魅力,首先將目標(biāo) collection view 的框架設(shè)定到最終位置,然后在 performBatchUpdates:completion:
的 update block 中執(zhí)行一個(gè)無(wú)動(dòng)畫的布局來(lái)改變至最終布局,緊隨其后便是在 completion block 中將 content insets 重置為原始值。
我們討論了兩種可以在 collection view 之間實(shí)現(xiàn)布局轉(zhuǎn)場(chǎng)的途徑。一種使用了內(nèi)置的 useLayoutToLayoutNavigationTransitions
,看起來(lái)令人印象深刻并且極其容易實(shí)現(xiàn),缺點(diǎn)就是可以使用的范圍較為局限。由于 useLayoutToLayoutNavigationTransitions
在一些案例中不能使用,想驅(qū)動(dòng)自定義的過(guò)渡動(dòng)畫的話,就需要一個(gè)自定義的 animator。這篇文章中,我們看到了如何實(shí)現(xiàn)這樣一個(gè) animator,然而,由于你的應(yīng)用程序大概肯定會(huì)需要在兩個(gè)和本例完全不同的 view 結(jié)構(gòu)中實(shí)現(xiàn)完全不同的動(dòng)畫,所以正如此例中做的那樣,不要吝于嘗試不同的方法來(lái)探究其是否能夠工作。