5-1 iOS --UICollectionView + UIKit Dynamics

UIKit Dynamics 是 iOS 7 中基于物理動畫引擎的一個新功能--它被特別設計使其能很好地與 collection views 配合工作,而后者是在 iOS 6 中才被引入的新特性。接下來,我們要好好看看如何將這兩個特性結合在一起。

這篇文章將討論兩個結合使用 UIkit Dynamics 和 collection view 的例子。第一個例子展示了如何去實現像 iOS 7 里信息 app 中的消息泡泡的彈簧動效,然后再進一步結合平鋪機制來實現布局的可伸縮性。第二個例子展現了如何用 UIKit Dynamics 來模擬牛頓擺,這個例子中物體可以一個個地加入到 collection view 中,并和其他物體發生相互作用。

在我們開始之前,我假定你們對UICollectionView是如何工作是有基本的了解——查看這篇 objc.io 文章會有你想要的所有細節。我也假定你已經理解了UIKit Dynamics的工作原理--閱讀這篇博客,可以了解更多 UIKit Dynamics 的知識。

編者注:如果您閱讀本篇文章感覺有點吃力的話,可以先來看看@onevcat《UICollectionView 入門》《UIKit Dynamics 入門》這兩篇入門文章,幫助您快速補充相關知識。

文章中的兩個例子項目都已經在 GitHub 中:

ASHSpringyCollectionView(基于UICollectionView Spring Demo

Newtownian UICollectionView

關于 UIDynamicAnimator

支持UICollectionView實現 UIKit Dynamics 的最關鍵部分就是UIDynamicAnimator。要實現這樣的 UIKit Dynamics 的效果,我們需要自己自定義一個繼承于UICollectionViewFlowLayout的子類,并且在這個子類對象里面持有一個 UIDynamicAnimator 的對象。

當我們創建自定義的 dynamic animator 時,我們不會使用常用的初始化方法-initWithReferenceView:,因為我們不需要把這個 dynamic animator 關聯一個 view ,而是給它關聯一個 collection view layout。所以我們使用-initWithCollectionViewLayout:這個初始化方法,并把 collection view layout 作為參數傳入。這很關鍵,當的 animator 的 behavior item 的屬性應該被更新的時候,它必須能夠確保 collection view 的 layout 失效。換句話說,dynamic animator 將會經常使舊的 layout 失效。

我們很快就能看到這些事情是怎么連接起來的,但是在概念上理解 collection view 如何與 dynamic animator 相互作用是很重要的。

Collection view layout 將會為 collection view 中的每個UICollectionViewLayoutAttributes添加 behavior(稍后我們會討論平鋪它們)。在將這些 behaviors 添加到 dynamic animator 之后,UIKit 將會向 collection view layout 詢問 atrribute 的狀態。我們此時可以直接將由 dynamic animator 所提供的 items 返回,而不需要自己做任何計算。Animator 將在模擬時禁用 layout。這會導致 UIKit 再次查詢 layout,這個過程會一直持續到模擬滿足設定條件而結束。

所以重申一下,layout 創建了 dynamic animator,并且為其中每個 item 的 layout attribute 添加對應的 behaviors。當 collection view 需要 layout 信息時,由 dynamic animator 來提供需要的信息。

繼承 UICollectionViewFlowLayout

我們將要創建一個簡單的例子來展示如何使用一個帶 UIkit Dynamic 的 collection view layout。當然,我們需要做的第一件事就是,創建一個數據源去驅動我們的 collection view。我知道以你的能力完全可以獨立實現一個數據源,但是為了完整性,我還是提供了一個給你:


我們注意到當 view 第一次出現的時候,這個 layout 是被無效的。這是因為沒有用 Storyboard 的結果(使用或不使用 Storyboard,調用 prepareLayout 方法的時機是不同的,蘋果在 WWDC 的視頻中并沒有告訴我們這一點)。所以,當這些視圖一出現我們就需要手動使這個 collection view layout 無效。當我們用平鋪(后面會詳細介紹)的時候,就不需要這樣。

現在來創建自定義的 collection view layout 吧,我們需要強引用一個 dynamic animator,并且使用它來驅動我們的 collcetion view layout 的 attribute。我們在實現文件里定義了一個私有屬性:


我們將在 layout 的初始化方法中初始化我們的 dynamic animator。還要設置一些屬于父類UICollectionViewFlowLayout中的屬性:


我們將實現的下一個方法是 prepareLayout。我們首先需要調用父類的方法。因為我們是繼承UICollectionViewFlowLayout類,所以在調用父類的 prepareLayout 方法時,可以使 collection view layout 的各個 attribute 都放置在合適的位置。我們可以依靠父類的這個方法來提供一個默認的排布,并且能夠使用[super layoutAttributesForElementsInRect:visibleRect];方法得到指定 rect 內的所有item 的 layout attributes。


真的是效率低下的代碼。因為我們的 collection view 中可能會有成千上萬個 cell,一次性加載所有的 cell 是一個可能會產生難以置信的內存緊張的操作。我們要在一段時間內遍歷所有的元素,這也成為耗時的操作。這真的是效率的雙重打擊!別擔心——我們是負責任的開發者,所以我們會很快解決這個問題的。我們先暫時繼續使用簡單、粗暴的實現方式。

當加載完我們所有的 collection view layout attribute 之后,我們需要檢查他們是否都已經被加載到我們的 animator 里了。如果一個 behavior 已經在 animator 中存在,那么我們就不能重新添加,否則就會得到一個非常難懂的運行異常提示:


如果看到了這個錯誤,那么這基本表明你添加了兩個 behavior 給同一個UICollectionViewLayoutAttribute,這使得系統不知道該怎么處理。

無論如何,一旦我們已經檢查好我們是否已經將 behavior 添加到 dynamic animator 之后,我們就需要遍歷每個 collection view layout attribute 來創建和添加新的 dynamic animator:


這段代碼非常簡單。我們為每個 item 創建了一個以物體的中心為附著點的UIAttachmentBehavior對象。然后又設置了我們的 attachment behavior 的 length 為 0 以便約束這個 cell 能一直以 behavior 的附著點為中心。然后又給damping和frequency這兩個參數設置一個比較合適的值。

這就是prepareLayout。我們現在需要實現layoutAttributesForElementsInRect:和layoutAttributesForItemAtIndexPath:這兩個方法,UIKit 會調用它們來詢問 collection view 每一個 item 的布局信息。我們寫的代碼會把這些查詢交給專門做這些事的 dynamic animator:


響應滾動事件

我們目前實現的代碼給我們展示的只是一個在正常滑動下只有靜態感覺的UICollectionView,運行起來沒什么特別的。看上去很好,但不是真的動態,不是么?

為了使它表現地動態點,我們需要 layout 和 dynamic animator 能夠對 collection view 中滑動位置的變化做出反應。幸好這里有個非常適合這個要求的方法shouldInvalidateLayoutForBoundsChange:。這個方法會在 collection view 的 bound 發生改變的時候被調用,根據最新的content offset調整我們的 dynamic animator 中的 behaviors 的參數。在重新調整這些 behavior 的 item 之后,我們在這個方法中返回 NO;因為 dynamic animator 會關心 layout 的無效問題,所以在這種情況下,它不需要去主動使其無效:


讓我們仔細查看這個代碼的細節。首先我們得到了這個 scroll view(就是我們的 collection view ),然后計算它的 content offset 中 y 的變化(在這個例子中,我們的 collection view 是垂直滑動的)。一旦我們得到這個增量,我們需要得到用戶接觸的位置。這是非常重要的,因為我們希望離接觸位置比較近的那些物體能移動地更迅速些,而離接觸位置比較遠的那些物體則應該滯后些。

對于 dynamic animator 中的每個 behavior,我們將接觸點到該 behavior 物體的 x 和 y 的距離之和除以 1500,1500 是我根據經驗設的。分母越小,這個 collection view 的的交互就越有彈簧的感覺。一旦我們拿到了這個“滑動阻力”的值,我們就可以用它的增量乘上scrollResistance這個變量來指定這個 behavior 物體的中心點的 y 值。最后,我們在滑動阻力大于增量的情況下對增量和滑動阻力的結果進行了選擇(這意味著物體開始往錯誤的方向移動了)。在本例我們用了這么大的分母,那么這種情況是不可能的,但是在一些更具彈性的 collection view layout 中還是需要注意的。

就是這么一回事。以我的經驗,這個方法對多達幾百個物體的 collection view 來說也是是適用的。超過這個數量的話,一次性加載所有物體到內存中就會變成很大的負擔,并且在滑動的時候就會開始卡頓了。

平鋪(Tiling)你的 Dynamic Behaviors 來優化性能

當你的 collection view 中只有幾百個 cell 的時候,他運行的很好,但當數據源超過這個范圍的時候會發生什么呢?或者在運行的時你不能預測你的數據源有多大呢?我們的簡單粗暴的方法就不管用了。

除了在prepareLayout中加載所有的物體,如果我們能更聰明地知道哪些物體會加載那該多好啊。是的,就是僅加載顯示的和即將顯示的物體。這正是我們要采取的辦法。

我們需要做的第一件事就是是跟蹤 dynamic animator 中的所有 behavior 物體的 index path。我在 collection view 中添加一個屬性來做這件事:

@property(nonatomic,strong) NSMutableSet *visibleIndexPathsSet;

我們用 set 是因為它具有常數復雜度的查找效率,并且我們經常地查找visibleIndexPathsSet中是否已經包含了某個 index path。

在我們實現全新的prepareLayout方法之前——有一個問題就是什么是平鋪 behavior—— 理解平鋪的意思是非常重要的。當我們平鋪behavior 的時候,我們會在這些 item 離開 collection view 的可視范圍的時候刪除對應的 behavior,在這些 item 進入可視范圍的時候又添加對應的 behavior。這是一個大麻煩:我們需要在滾動中創建新的 behavior。這就意味著讓人覺得創建它們就好像它們本來就已經在 dynamic animator 里了一樣,并且它們是在shouldInvalidateLayoutForBoundsChange:方法被修改的。

因為我們是在滾動中創建這些新的 behavior,所以我們需要維持現在 collection view 的一些狀態。尤其我們需要跟蹤最近一次我們bound變化的增量。我們會在滾動時用這個狀態去創建我們的 behavior:

@property(nonatomic,assign)CGFloatlatestDelta;

添加完這個 property 后,我們將要在shouldInvalidateLayoutForBoundsChange:方法中添加下面這行代碼:

self.latestDelta = delta;

這就是我們需要修改我們的方法來響應滾動事件。我們的這兩個方法是為了將 collection view 中 items 的 layout 信息傳給 dynamic animator,這種方式沒有變化。事實上,當你的 collection view 實現了 dynamic animator 的大部分情況下,都需要實現我們上面提到的兩個方法layoutAttributesForElementsInRect:和layoutAttributesForItemAtIndexPath:。

這里最難懂的部分就是平鋪機制。我們將要完全重寫我們的 prepareLayout。

這個方法的第一步是將那些物體的 index path 已經不在屏幕上顯示的 behavior 從 dynamic animator 上刪除。第二步是添加那些即將顯示的物體的 behavior。

讓我們先看一下第一步。

像以前一樣,我們要調用super prepareLayout,這樣我們就能依賴父類UICollectionViewFlowLayout提供的默認排布。還像以前一樣,我們通過父類獲取一個矩形內的所有元素的 layout attribute。不同的是我們不是獲取整個 collection view 中的元素屬性,而只是獲取顯示范圍內的。

所以我們需要計算這個顯示矩形。但是別著急!有件事要記住。我們的用戶可能會非常快地滑動 collection view,導致了 dynamic animator 不能跟上,所以我們需要稍微擴大顯示范圍,這樣就能包含到那些將要顯示的物體了。否則,在滑動很快的時候就會出現頻閃現象了。讓我們計算一下顯示范圍:


我確信在實際顯示矩形上的每個方向都擴大100個像素對我的 demo 來說是可行的。仔細查看這些值是否適合你們的 collection view,尤其是當你們的 cell 很小的情況下。

接下來我們就需要收集在顯示范圍內的 collection view layout attributes。還有它們的 index paths:


注意我們是在用一個 NSSet。這是因為它具有常數復雜度的查找效率,并且我們經常的查找visibleIndexPathsSet是否已經包含了某個 index path:

接下來我們要做的就是遍歷 dynamic animator 的 behaviors,過濾掉那些已經在itemsIndexPathsInVisibleRectSet中的 item。因為我們已經過濾掉我們的 behavior,所以我們將要遍歷的這些 item 都是不在顯示范圍里的,我們就可以將這些 item 從 animator 中刪除掉(連同visibleIndexPathsSet屬性中的 index path):


下一步就是要得到出現 item 的UICollectionViewLayoutAttributes數組——那些 item 的 index path 在itemsIndexPathsInVisibleRectSet而不在visibleIndexPathsSet:


一旦我們有新的 layout attribute 出現,我就可以遍歷他們來創建新的 behavior,并且將他們的 index path 添加到visibleIndexPathsSet中。首先,無論如何,我都需要獲取到用戶手指觸碰的位置。如果它是CGPointZero的話,那就表示這個用戶沒有在滑動 collection view,這時我就假定我們不需要在滾動時創建新的 behavior 了:

CGPointtouchLocation = [self.collectionView.panGestureRecognizerlocationInView:self.collectionView];

這是一個潛藏危險的假定。如果用戶很快地滑動了 collection view 之后釋放了他的手指呢?這個 collection view 就會一直滾動,但是我們的方法就不會在滾動時創建新的 behavior 了。但幸運的是,那也就意味這時 scroll view 滾動太快很難被注意到!好哇!但是,對于那些擁有大型 cell 的 collection view 來說,這仍然是個問題。那么在這種情況下,就需要增加你的可視范圍的 bounds 來加載更多物體以解決這個問題。

現在我們需要枚舉我們剛顯示的 item,為他們創建 behavior,再將他們的 index path 添加到visibleIndexPathsSet。我們還需要在滾動時做些數學運算來創建 behavior:


大部分代碼看起來還是挺熟悉的。大概有一半是來自沒有實現平鋪的prepareLayout。另一半是來自shouldInvalidateLayoutForBoundsChange:這個方法。我們用 latestDelta 這個屬性來表示bound變化的增量,適當地調整UICollectionViewLayoutAttributes使這些 cell 表現地就像被 attachment behavior “拉”著一樣。

就這樣就完成了,真的!我已經在真機上測試過顯示上千個 cell 的情況了,它運行地非常完美。去試試吧

超越瀑布流布局

一般來說,當我們使用UICollectionView的時候,繼承UICollectionViewFlowLayout會比直接繼承UICollectionViewLayout更容易。這是因為flowlayout 會為我們做很多事。然而,瀑布流布局是嚴格基于它們的尺寸一個接一個的展現出來。如果你有一個布局不能適應這個標準怎么辦?好的,如果你已經嘗試用UICollectionViewFlowLayout來適應,而且你很確定它不能很好運行,那么就應該拋棄UICollectionViewFlowLayout這個定制性比較弱的子類,而應該直接在UICollectionViewLayout這個基類上進行定制。

這個原則在處理 UIKit Dynamic 時也是適用的。

讓我們先創建UICollectionViewLayout的子類。當繼承UICollectionViewLayout的時候需要實現collectionViewContentSize方法,這點非常重要。否則這個 collection view 就不知道如果去顯示自己,也不會有顯示任何東西。因為我們想要 collection view 不能滾動,所以這里要返回 collection view 的 frame 的 size,減去它的contentInset.top:


在這個(有點教學式)的例子中,我們的 collection view總是會以零個cell開始,物體通過performBatchUpdates:這個方法添加。這就意味著我們必須使用-[UICollectionViewLayout prepareForCollectionViewUpdates:]這個方法來添加我們的 behavior(即這個 collection view 的數據源總是以零開始)。

除了給各個 item 添加 attachment behavior 外,我們還將保留另外兩個 behavior:重力和碰撞。對于添加在這個 collection view 中的每個 item 來說,我們必須把這些 item 添加到我們的碰撞和 attachment behavior 中。最后一步就是設置這些 item 的初始位置為屏幕外的某些地方,這樣就有被 attachment behavior 拉入到屏幕內的效果了:



刪除就有點復雜了。我們希望這些物體有“掉落”的效果而不是簡單的消失。這就不僅僅是從 collection view 中刪除個 cell 這么簡單了,因為我們希望在它離開了屏幕之前還是保留它。我已經在代碼中實現了這樣的效果,但是做法有點取巧。

基本上我們要做的是在 layout 中提供一個方法,在它刪除 attachment behavior 兩秒之后,將這個 cell 從 collection view 中刪除。我們希望在這段時間里,這個 cell 能掉出屏幕,但是這不一定會發生。如果沒有發生,也沒關系。只要淡出就行了。然而,我們必須保證在這兩秒內既沒有新的 cell 被添加,也沒有舊的 cell 被刪除。(我說了有點取巧。)

歡迎提交 pull request。

這個方法是有局限性的。我將 cell 數量的上限設為 10,但是即使這樣,在像 iPad2 這樣比較舊的設備中,動畫就會運行地很慢。當然,這個例子只是為了展示如何模擬有趣的動力學的一個方法——它并不是一個可以解決任何問題的萬金油。你個人在實踐中如何來進行模擬,包括性能等各個方面,都取決于你自己了。


翻譯作者:黃宣冬

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

推薦閱讀更多精彩內容