objc系列譯文(5.2):UICollectionView 和 UIKit Dynamics

發表於2014-04-24

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 的知識。

文章中的兩個例子專案都已經在 GitHub 中:

關於 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 中新增一個屬性來做這件事:

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

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

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

新增完這個 property 後,我們將要在 shouldInvalidateLayoutForBoundsChange: 方法中新增下面這行程式碼:

這就是我們需要修改我們的方法來響應滾動事件。我們的這兩個方法是為了將 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 了:

這是一個潛藏危險的假定。如果使用者很快地滑動了 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 更容易。這是因為 flow layout 會為我們做很多事。然而,瀑布流佈局是嚴格基於它們的尺寸一個接一個的展現出來。如果你有一個佈局不能適應這個標準怎麼辦?好的,如果你已經嘗試用 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 這樣比較舊的裝置中,動畫就會執行地很慢。當然,這個例子只是為了展示如何模擬有趣的動力學的一個方法——它並不是一個可以解決任何問題的萬金油。你個人在實踐中如何來進行模擬,包括效能等各個方面,都取決於你自己了。

 

相關文章