Collection View - 學習筆記

UICollectionView和相關類的設置非常靈活和強大。但是靈活性一旦增強,某種程度上也增加了其復雜性: UICollectionView比老式的 UITableView
更有深度,適用性也更強。

Collection View 深入太多了,事實上,Ole BegemanAsh Furrow 之前曾在 objc.io 上發表過 自定義 Collection View 布局UICollectionView + UIKit 力學,但是我依然有一些他們沒有提及的內容可以寫。在這篇文章中,我假設你已經非常熟悉 UICollectionView的基本布局,并且至少閱讀了蘋果精彩的編程指南以及 Ole 之前的文章。

本文的第一部分將集中討論并舉例說明如何用不同的類和方法來共同幫助實現一些常見的 UICollectionView動畫。在第二部分,我們將看一下帶有 collection views 的 view controller 轉場動畫以及在 useLayoutToLayoutNavigationTransitions可用時使用其進行轉場,如果不可用時,我們會實現一個自定義轉場動畫。

你可以在 GitHub 中找到本文提到的兩個示例工程:
布局動畫
自定義 collection view 轉場動畫


Collection View 布局動畫

標準 UICollectionViewFlowLayout 除了動畫是非常容易自定義的,蘋果選擇了一種安全的途徑去實現一個簡單的淡入淡出動畫作為所有布局的默認動畫。如果你想實現自定義動畫,最好的辦法是子類化 UICollectionViewFlowLayout 并且在適當的地方實現你的動畫。讓我們通過一些例子來了解 UICollectionViewFlowLayout 子類中的一些方法如何協助完成自定義動畫。

插入刪除元素

一般來說,我們對布局屬性從初始狀態到結束狀態進行線性插值來計算 collection view 的動畫參數。然而,新插入或者刪除的元素并沒有最初或最終狀態來進行插值。要計算這樣的 cells 的動畫,collection view 將通過 initialLayoutAttributesForAppearingItemAtIndexPath: 以及 finalLayoutAttributesForDisappearingItemAtIndexPath: 方法來詢問其布局對象,以獲取最初的和最后的屬性。蘋果默認的實現中,對于特定的某個 indexPath,返回的是它的通常的位置,但 alpha 值為 0.0,這就產生了一個淡入或淡出動畫。如果你想要更漂亮的效果,比如你的新的 cells 從屏幕底部發射并且旋轉飛到對應位置,你可以如下實現這樣的布局子類:

- (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;
}

結果如下:


插入與刪除
插入與刪除

對應的 finalLayoutAttributesForDisappearingItemAtIndexPath:
方法中,除了設定了不同的 transform 以外,其他都很相似。

響應設備旋轉

設備方向變化通常會導致 collection view 的 bounds 變化。如果通過 shouldInvalidateLayoutForBoundsChange: 判定為布局需要被無效化并重新計算的時候,布局對象會被詢問以提供新的布局。UICollectionViewFlowLayout 的默認實現正確地處理了這個情況,但是如果你子類化 UICollectionViewLayout 的話,你需要在邊界變化時返回 YES:

- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
{
    CGRect oldBounds = self.collectionView.bounds;
    if (!CGSizeEqualToSize(oldBounds.size, newBounds.size)) {
        return YES;
    }
    return NO;
}

在 bounds 變化的動畫中,collection view 表現得像當前顯示的元素被移除然后又在新的 bounds 中被被重新插入,這會對每個 IndexPath 產生一系列的 finalLayoutAttributesForDisappearingItemAtIndexPath:
和 initialLayoutAttributesForAppearingItemAtIndexPath:
的調用。
如果你在插入和刪除的時候加入了非常炫的動畫,現在你應該看看為何蘋果明智的使用簡單的淡入淡出動畫作為默認效果:


設備旋轉的錯誤反應
設備旋轉的錯誤反應

啊哦...
為了防止這種不想要的動畫,初始化位置 -> 刪除動畫 -> 插入動畫 -> 最終位置的順序必須完全匹配 collection view 的每一項,以便最終呈現出一個平滑動畫。換句話說,finalLayoutAttributesForDisappearingItemAtIndexPath:
以及 initialLayoutAttributesForAppearingItemAtIndexPath:
應該針對元素到底是真的在顯示或者消失,還是 collection view 正在經歷的邊界改變動畫的不同情況,做出不同反應,并返回不同的布局屬性。
幸運的是,collection view 會告知布局對象哪一種動畫將被執行。它分別通過調用 prepareForAnimatedBoundsChange:
和 prepareForCollectionViewUpdates:
來對應 bounds 變化以及元素更新。出于本實例的說明目的,我們可以使用 prepareForCollectionViewUpdates:
來跟蹤更新對象:

- (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 時進行發射:

- (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
來返回一個普通的屬性,以此取消特殊的外觀動畫。結合 finalLayoutAttributesForDisappearingItemAtIndexPath:
中相應的邏輯,最終將會使元素能夠在 bounds 變化時,從初始位置到最終位置以很流暢的動畫形式實現,從而建立一個簡單但很酷的動畫效果:


設備旋轉錯誤反應
設備旋轉錯誤反應
交互式布局動畫

Collection views 讓用戶通過手勢實現與布局交互這件事變得很容易。如蘋果建議的那樣,為 collection view 布局添加交互的途徑一般會遵循以下步驟:

  1. 創建手勢識別
  2. 將手勢識別添加給 collection view
  3. 通過手勢來驅動布局動畫

讓我們來看看我們如何可以建立一些用戶可縮放捏合的元素,以及一旦用戶釋放他們的捏合手勢元素返回到原始大小。
我們的處理方式可能會是這樣:

- (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];
    }
}

這個捏合操作需要計算捏合距離并找出被捏合的元素,并且在用戶捏合的時候通知布局以實現自身更新。當捏合手勢結束的時候,布局會做一個批量更新動畫返回原始尺寸。

另一方面,我們的布局始終在跟蹤捏合的元素以及期望尺寸,并在需要的時候提供正確的屬性:

- (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;
}
小結

我們通過一些例子來說明了如何在 collection view 布局中創建自定義動畫。雖然 UICollectionViewFlowLayout
并不直接允許定制動畫,但是蘋果工程師提供了清晰的架構讓你可以子類化并實現各種自定義行為。從本質來說,在你的 UICollectionViewLayout
子類中正確地響應以下信號,并對那些要求返回 UICollectionViewLayoutAttributes
的方法返回合適的屬性,那么實現自定義布局和動畫的唯一約束就是你的想象力:

  • prepareLayout
  • prepareForCollectionViewUpdates:
  • finalizeCollectionViewUpdates
  • prepareForAnimatedBoundsChange:
  • finalizeAnimatedBoundsChange
  • shouldInvalidateLayoutForBoundsChange:

更引人入勝的動畫可以結合像在 objc.io 話題 #5 中 UIKit 力學這樣的技術來實現。


帶有 Collection views 的 View controller 轉場

就如 Chris 之前在 objc.io 的文章中所說的那樣,iOS 7 中的一個重大更新是自定義 view controller 轉場動畫。與自定義轉場動畫相呼應,蘋果也在 UICollectionViewController
添加了 useLayoutToLayoutNavigationTransitions
標記來在可復用的單個 collection view 間啟用導航轉場。蘋果自己的照片和日歷應用就是這類轉場動畫的非常好的代表作。

UICollectionViewController 實例之間的轉場動畫

讓我們來看看我們如何能夠利用上一節相同的示例項目達到類似的效果:


導航轉換問題
導航轉換問題

為了使布局到布局的轉場動畫工作,navigation controller 的 root view controller 必須是一個 useLayoutToLayoutNavigationTransitions設置為 NO的 collection view controller。
當另一個 useLayoutToLayoutNavigationTransitions設置為 YES的 UICollectionViewController實例被 push 到根視圖控制器之上時,navigation controller 會用布局轉場動畫來代替標準的 push 轉場動畫。這里要注意一個重要的細節,根視圖控制器的 collection view 實例被回收用于在導航棧上 push 進來的 collection 控制器中,如果你試圖在 viewDidLoad
之類的方法中中設置 collection view 屬性, 它們將不會有任何反應,你也不會收到任何警告。

這個行為可能最常見的陷阱是期望回收的 collection view 根據頂層的 collection 視圖控制器來更新數據源和委托。它當然不會這樣:根 collection 視圖控制器會保持數據源和委托,除非我們做點什么。

解決此問題的方法是實現 navigation controller 的委托方法,并根據導航堆棧頂部的當前視圖控制器的需要正確設置 collection view 的數據源和委托。在我們簡單的例子中,這可以通過以下方式實現:

- (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;
    }
}

當詳細頁面的 collection view 被推入導航棧時,我們重新設置 collection view 的數據源到詳細視圖控制器,確保只有被選擇的 cell 顏色顯示在詳細頁面的 collection view 中。如果我們不打算這樣做,布局依然可以正確過渡,但是collection 將顯示所有的 cells。在實際應用中,detail 的數據源通常負責在轉場動畫過程中顯示更詳細的數據。

用于常規轉換的 Collection View 布局動畫

使用了 useLayoutToLayoutNavigationTransitions
的布局和布局間導航轉換是很有用的,但卻局限于僅在 兩個 view controller 都是 UICollectionViewController
的實例,并且轉場的必須發生在頂級 collection views 之間。為了達到在任意視圖控制器的任意 collection view 之間都能實現相似的過渡,我們需要自定義一個 view collection 的轉場動畫。


自定義視圖轉換
自定義視圖轉換

針對此類自定義過渡的動畫控制器,需要遵循以下步驟進行設計:

  1. 對初始的 collection view 中的所有可見元素制作截圖
  2. 將截圖添加到轉場上下文的 container view 中
  3. 運用目標 collection view 的布局計算最終位置
  4. 制作動畫使快照到正確的位置
  5. 當目標 collection view 可見時刪除截圖

一個這樣的動畫設計有兩重缺陷:它只能對初始的 collection view 的可見元素制作動畫,因為快照 APIs 只能工作于屏幕上可見的 view,另外,依賴于可見的元素數量,可能會有很多的 views 需要進行正確的跟蹤并為其制作動畫。但另一方面,這種設計又具有一個明顯的優勢,那就是它可以為所有類型的 UICollectionViewLayout
組合所使用。這樣一個系統的實現就留給讀者們去進行練習吧。
在附帶的演示項目中我們用另一種途徑進行了實現,它依賴于一些 UICollectionViewFlowLayout
的巧合。
基本的想法是,因為源 collection view 和目標 collection view 都擁有有效的 flow layouts,因此源 layout 的布局屬性正好可以用作目標 collection view 的布局中的初始布局屬性,以此驅動轉場動畫。一旦正確建立,就算對于那些一開始在屏幕上不可見的元素,collection view 的機制都將為我們追蹤它們并進行動畫。下面是我們的動畫控制器中的 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;

    //制作原來布局的拷貝
    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;

    //強制在目標 collection view 中設定一個很大的 bottom inset
    contentInset.bottom = CGRectGetHeight(finalRect)-(toLayout.itemSize.height+toLayout.sectionInset.bottom+toLayout.sectionInset.top);
    self.toCollectionView.contentInset = contentInset;

    //將源布局設置給目標 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 制作動畫
         toView.frame = finalRect;
         //在 performUpdates 中設定最終的布局
         [_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];
     }];

首先,動畫控制器確保目標 collection view 以與原來的 collection view 完全相同的框架和布局作為開始。接著,它將源 collection view 的布局設定給目標 collection view,以確保其不會失效。與此同時,該布局已經復制到另一個新的布局對象中,而這個布局對象則是為防止在導航回原始視圖控制器時出現奇怪的布局 bug。我們還會強制在目標 collection view 的底部設定一個很大的 content inset,來確保布局在動畫的初始位置時保持在一行上。觀察日志的話,你會發現由于元素的尺寸加上 inset 的尺寸會比 collection view 的非滾動維度要大,因此 collection view 會在控制臺警告。在這樣的情況下,collection view 的行為是沒有定義的,我們也只是使用這樣一個不穩定的狀態來作為我們轉換動畫的初始狀態。最后,復雜的動畫 block 將展現它的魅力,首先將目標 collection view 的框架設定到最終位置,然后在 performBatchUpdates:completion: 的 update block 中執行一個無動畫的布局來改變至最終布局,緊隨其后便是在 completion block 中將 content insets 重置為原始值。

小結

我們討論了兩種可以在 collection view 之間實現布局轉場的途徑。一種使用了內置的 useLayoutToLayoutNavigationTransitions,看起來令人印象深刻并且極其容易實現,缺點就是可以使用的范圍較為局限。由于 useLayoutToLayoutNavigationTransitions 在一些案例中不能使用,想驅動自定義的過渡動畫的話,就需要一個自定義的 animator。這篇文章中,我們看到了如何實現這樣一個 animator,然而,由于你的應用程序大概肯定會需要在兩個和本例完全不同的 view 結構中實現完全不同的動畫,所以正如此例中做的那樣,不要吝于嘗試不同的方法來探究其是否能夠工作。

原文 Animating Collection Views,轉自—Collection View 動畫

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • objc系列譯文(12.5):Collection View 動畫 2014/05/28 ·iOS,開發·iOS,...
    紅酒佳坊閱讀 1,090評論 7 6
  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,214評論 4 61
  • 《禮記·大學》中寫道: “富潤屋,德潤身,心廣體胖?!?財富可以裝飾房屋 品德卻可以修養身心 使心胸寬廣而身體舒泰...
    鈺婧Erica閱讀 359評論 0 0
  • 一、心1、養心陰安心神----酸棗仁,柏子仁,地黃,龍眼肉,丹參,麥冬,當歸,白芍,龜板,浮小麥,阿膠,紫河車,百...
    友品中醫閱讀 663評論 0 0
  • 在經歷了無數的舶來綜藝洗禮后,國內終于出現了讓浮躁的人心靜下來,享受文化熏陶的原創綜藝,不再只是靠各種流量明星吸引...
    kristen_996閱讀 401評論 0 0