UICollectionView 在 iOS6 中第一次被引入,也是 UIKit視圖類中的一顆新星。它和 UITableView 共享一套 API 設計,但也在 UITableView 上做了一些擴展。UICollectionView 最強大、同時顯著超出 UITableView 的特色就是其完全靈活的布局結構。在這篇文章中,我們將會實現一個相當復雜的自定義 collection view 布局,并且順便討論一下這個類設計的重要部分。項目的示例代碼在GitHub上。
布局對象 (Layout Objects)
UITableView 和 UICollectionView 都是data-source 和 delegate 驅動的。它們在顯示其子視圖集的過程中僅扮演容器角色(dumb containers),且對子視圖集真正的內容毫不知情。
UICollectionView在此之上進行了進一步抽象。它將其子視圖的位置,大小和外觀的控制權委托給一個單獨的布局對象。通過提供一個自定義布局對象,你幾乎可以實現任何你能想象到的布局。布局繼承自UICollectionViewLayout抽象基類。iOS6 中以UICollectionViewFlowLayout類的形式提出了一個具體的布局實現。
我們可以使用 flow layout 實現一個標準的 grid view,這可能是在 collection view 中最常見的使用案例了。盡管大多數人都這么想,但是 Apple 很聰明,沒有明確的命名這個類為UICollectionViewGridLayout,而使用了更為通用的術語 flow layout,更好的描述了該類的功能:它通過一個接一個的放置 cell 來建立自己的布局,當需要的時候,插入橫排或豎排的分欄符。通過自定義滾動方向,大小和 cell 之間的間距,flow layout 也可以在單行或單列中布局 cell。實際上,UITableView的布局可以想象成 flow layout 的一種特殊情況。
在你準備自己寫一個UICollectionViewLayout的子類之前,你需要問你自己,你是否能夠使用UICollectionViewFlowLayout實現你心里的布局。這個類是很容易定制的,并且可以繼承本身進行近一步的定制。感興趣的看這篇文章。
Cells 和其他 Views
為了適應任意布局,collection view 建立一個了類似、但比 table view 更靈活的視圖層級(view hierarchy)。像往常一樣,你的主要內容顯示在 cell 中,cell 可以被任意分組到 section 中。Collection view 的 cell 必須是UICollectionViewCell的子類。除了 cell,collection view 額外管理著兩種視圖:supplementary views 和 decoration views。
collection view 中的Supplementary views相當于 table view 的 section header 和 footer views。像 cells 一樣,他們的內容都由數據源對象驅動。然而和 table view 中用法不一樣,supplementary view 并不一定會作為 header 或 footer view;他們的數量和放置的位置完全由布局控制。
Decoration views純粹為一個裝飾品。他們完全屬于布局對象,并被布局對象管理,他們并不從 data source 獲取的 contents。當布局對象指定需要一個 decoration view 的時候,collection view 會自動創建,并將布局對象提供的布局參數應用到上面去。并不需要為自定義視圖準備任何內容。
Supplementary views 和 decoration views 必須是UICollectionReusableView的子類。布局使用的每個視圖類都需要在 collection view 中注冊,這樣當 data source 讓它們從 reuse pool 中出列時,它們才能夠創建新的實例。如果你是使用的 Interface Builder,則可以通過在可視編輯器中拖拽一個 cell 到 collection view 上完成 cell 在 collection view 中的注冊。同樣的方法也可以用在 supplementary view 上,前提是你使用了UICollectionViewFlowLayout。如果沒有,你只能通過調用registerClass:或者registerNib:方法手動注冊視圖類了。你需要在viewDidLoad中做這些操作。
自定義布局
作為一個非常有意義的自定義 collection view 布局的例子,我們不妨設想一個典型的日歷應用程序中的周 (week) 視圖。日歷一次顯示一周,星期中的每一天顯示在列中。每一個日歷事件將會在我們的 collection view 中以一個 cell 顯示,位置和大小代表事件起始日期時間和持續時間。
一般有兩種類型的 collection view 布局:
1.獨立于內容的布局計算。這正是你所知道的像 UITableView 和 UICollectionViewFlowLayout 這些情況。每個 cell 的位置和外觀不是基于其顯示的內容,但所有 cell 的顯示順序是基于內容的順序。可以把默認的 flow layout 做為例子。每個 cell 都基于前一個 cell 放置(或者如果沒有足夠的空間,則從下一行開始)。布局對象不必訪問實際數據來計算布局。
2.基于內容的布局計算。我們的日歷視圖正是這樣類型的例子。為了計算顯示事件的起始和結束時間,布局對象需要直接訪問 collection view 的數據源。在很多情況下,布局對象不僅需要取出當前可見 cell 的數據,還需要從所有記錄中取出一些決定當前哪些 cell 可見的數據。
在我們的日歷示例中,布局對象如果訪問某一個矩形內 cells 的屬性,那就必須迭代數據源提供的所有事件來決定哪些位于要求的時間窗口中。 與一些相對簡單,數據源獨立計算的 flow layout 比起來,這足夠計算出 cell 在一個矩形內的 index paths 了(假設網格中所有cells的大小都一樣)。
如果有一個依賴內容的布局,那就是暗示你需要寫自定義的布局類了,同時不能使用自定義的UICollectionViewFlowLayout,所以這正是我們需要做的事情。
UICollectionViewLayout的文檔列出了子類需要重寫的方法。
collectionViewContentSize
由于 collection view 對它的 content 并不知情,所以布局首先要提供的信息就是滾動區域大小,這樣 collection view 才能正確的管理滾動。布局對象必須在此時計算它內容的總大小,包括 supplementary views 和 decoration views。注意,盡管大多數經典的 collection view 限制在一個軸方向上滾動(正如UICollectionViewFlowLayout一樣),但這不是必須的。
在我們的日歷示例中,我們想要視圖垂直的滾動。比如,如果我們想要在垂直空間上一個小時占去 100 點,這樣顯示一整天的內容高度就是 2400 點。注意,我們不能夠水平滾動,這就意味這我們 collection view 只能顯示一周。為了能夠在日歷中的多個星期間分頁,我們可以在一個獨立(分頁)的 scroll view (可以使用UIPageViewController)中使用多個collection view(一周一個),或者堅持使用一個 collection view 并且返回足夠大的內容寬度,這會使得用戶感覺在兩個方向上滑動自由。
為了清楚起見,我選擇布局在一個非常簡單的模型上:假定每周天數相同,每天時長相同,也就是說天數用 0-6 表示。在一個真實的日歷程序中,布局將會為自己的計算大量使用基于NSCalendaar的日期。
layoutAttributesForElementsInRect:
這是任何布局類中最重要的方法了,同時可能也是最容易讓人迷惑的方法。collection view 調用這個方法并傳遞一個自身坐標系統中的矩形過去。這個矩形代表了這個視圖的可見矩形區域(也就是它的 bounds ),你需要準備好處理傳給你的任何矩形。
你的實現必須返回一個包含UICollectionViewLayoutAttributes對象的數組,為每一個 cell 包含一個這樣的對象,supplementary view 或 decoration view 在矩形區域內是可見的。UICollectionViewLayoutAttributes類包含了 collection view 內 item 的所有相關布局屬性。默認情況下,這個類包含frame,center,size,transform3D,alpha,zIndex和hidden屬性。如果你的布局想要控制其他視圖的屬性(比如背景顏色),你可以建一個UICollectionViewLayoutAttributes的子類,然后加上你自己的屬性。
布局屬性對象 (layout attributes objects) 通過indexPath屬性和他們對應的 cell,supplementary view 或者 decoration view 關聯在一起。collection view 為所有 items 從布局對象中請求到布局屬性后,它將會實例化所有視圖,并將對應的屬性應用到每個視圖上去。
注意!這個方法涉及到所有類型的視圖,也就是 cell,supplementary views 和 decoration views。一個幼稚的實現可能會選擇忽略傳入的矩形,并且為 collection view 中的所有視圖返回布局屬性。在原型設計和開發布局階段,這是一個有效的方法。但是,這將對性能產生非常壞的影響,特別是可見 cell 遠少于所有 cell 數量的時候,collection view 和布局對象將會為那些不可見的視圖做額外不必要的工作。
你的實現需要做這幾步:
1, 創建一個空的可變數組來存放所有的布局屬性。
2, 確定 index paths 中哪些 cells 的 frame 完全或部分位于矩形中。這個計算需要你從 collection view 的數據源中取出你需要顯示的數據。然后在循環中調用你實現的layoutAttributesForItemAtIndexPath:方法為每個 index path 創建并配置一個合適的布局屬性對象,并將每個對象添加到數組中。
3, 如果你的布局包含 supplementary views,計算矩形內可見 supplementary view 的 index paths。在循環中調用你實現的layoutAttributesForSupplementaryViewOfKind:atIndexPath:,并且將這些對象加到數組中。通過為 kind 參數傳遞你選擇的不同字符,你可以區分出不同種類的supplementary views(比如headers和footers)。當需要創建視圖時,collection view 會將 kind 字符傳回到你的數據源。記住 supplementary 和 decoration views 的數量和種類完全由布局控制。你不會受到 headers 和 footers 的限制。
4, 如果布局包含 decoration views,計算矩形內可見 decoration views 的 index paths。在循環中調用你實現的layoutAttributesForDecorationViewOfKind:atIndexPath:,并且將這些對象加到數組中。
5, 返回數組。
我們自定義的布局沒有使用 decoration views,但是使用了兩種 supplementary views(column headers和row headers):
layoutAttributesFor…IndexPath
有時,collection view 會為某個特殊的 cell,supplementary 或者 decoration view 向布局對象請求布局屬性,而非所有可見的對象。這就是當其他三個方法開始起作用時,你實現的layoutAttributesForItemAtIndexPath:需要創建并返回一個單獨的布局屬性對象,這樣才能正確的格式化傳給你的 index path 所對應的 cell。
你可以通過調用+[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:]這個方法,然后根據 index path 修改屬性。為了得到需要顯示在這個 index path 內的數據,你可能需要訪問 collection view 的數據源。到目前為止,至少確保設置了 frame 屬性,除非你所有的 cell 都位于彼此上方。
如果你正在使用自動布局,你可能會感到驚訝,我們正在直接修改布局參數的 frame 屬性,而不是和約束共事,但這正是 UICollectionViewLayout 的工作。盡管你可能使用自動布局來定義collection view 的 frame 和它內部每個 cell 的布局,但 cells 的 frames 還是需要通過老式的方法計算出來。
類似的,layoutAttributesForSupplementaryViewOfKind:atIndexPath:和layoutAttributesForDecorationViewOfKind:atIndexPath:方法分別需要為 supplementary 和 decoration views 做相同的事。只有你的布局包含這樣的視圖你才需要實現這兩個方法。UICollectionViewLayoutAttributes包含另外兩個工廠方法,+layoutAttributesForSupplementaryViewOfKind:withIndexPath:和+layoutAttributesForDecorationViewOfKind:withIndexPath:,用他們來創建正確的布局屬性對象。
shouldInvalidateLayoutForBoundsChange:
最后,當 collection view 的 bounds 改變時,布局需要告訴 collection view 是否需要重新計算布局。我的猜想是:當 collection view 改變大小時,大多數布局會被作廢,比如設備旋轉的時候。因此,一個幼稚的實現可能只會簡單的返回 YES。雖然實現功能很重要,但是 scroll view 的 bounds 在滾動時也會改變,這意味著你的布局每秒會被丟棄多次。根據計算的復雜性判斷,這將會對性能產生很大的影響。
當 collection view 的寬度改變時,我們自定義的布局必須被丟棄,但這滾動并不會影響到布局。幸運的是,collection view 將它的新 bounds 傳給shouldInvalidateLayoutForBoundsChange:方法。這樣我們便能比較視圖當前的bounds 和新的 bounds 來確定返回值:
動畫
插入和刪除
UITableView 中的 cell 自帶了一套非常漂亮的插入和刪除動畫。但是當為 UICollectionView 增加和刪除 cell 定義動畫功能時,UIKit 工程師遇到這樣一個問題:如果 collection view 的布局是完全可變的,那么預先定義好的動畫就沒辦法和開發者自定義的布局很好的融合。他們提出了一個優雅的方法:當一個 cell (或者supplementary或者decoration view)被插入到 collection view 中時,collection view 不僅向其布局請求 cell 正常狀態下的布局屬性,同時還請求其初始的布局屬性,比如,需要在開始有插入動畫的 cell。collection view 會簡單的創建一個 animation block,并在這個 block 中,將所有 cell 的屬性從初始(initial)狀態改變到常態(normal)。
通過提供不同的初始布局屬性,你可以完全自定義插入動畫。比如,設置初始的 alpha 為 0 將會產生一個淡入的動畫。同時設置一個平移和縮放將會產生移動縮放的效果。
同樣的原理應用到刪除上,這次動畫是從常態到一系列你設置的最終布局屬性。這些都是你需要在布局類中為initial或final布局參數實現的方法.
initialLayoutAttributesForAppearingItemAtIndexPath:
initialLayoutAttributesForAppearingSupplementaryElementOfKind:atIndexPath:
initialLayoutAttributesForAppearingDecorationElementOfKind:atIndexPath:
finalLayoutAttributesForDisappearingItemAtIndexPath:
finalLayoutAttributesForDisappearingSupplementaryElementOfKind:atIndexPath:
finalLayoutAttributesForDisappearingDecorationElementOfKind:atIndexPath:
布局間切換
可以通過類似的方式將一個 collection view 布局動態的切換到另外一個布局。當發送一個setCollectionViewLayout:animated:消息時,collection view 會為 cells 在新的布局中查詢新的布局參數,然后動態的將每個 cell(通過index path在新舊布局中判斷出相同的cell)從舊參數變換到新的布局參數。你不需要做任何事情。
結論
根據自定義 collection view 布局的復雜性,寫一個通常很不容易。確切的說,本質上這和從頭寫一個完整的實現相同布局自定義視圖類一樣困難了。因為所涉及的計算需要確定哪些子視圖當前是可見的,以及它們的位置。盡管如此,使用UICollectionView還是給你帶來了一些很好的效果,比如 cell 重用,自動支持動畫,更不要提整潔的獨立布局,子視圖管理,以及數據提供架構規定(data preparation its architecture prescribes.)。
自定義 collection view 布局也是向輕量級 view controller邁出很好的一步,正如你的 view controller 不要包含任何布局代碼。正如 Chris 的文章中解釋的一樣,將這一切和一個獨立的 datasource 類結合在一起,collection view 的視圖控制器將很難再包含任何代碼。
每當我使用UICollectionView的時候,我被其簡潔的設計所折服。對于一個有經驗的 Apple 工程師,為了想出如此靈活的類,很可能需要首先考慮NSTableView和UITableView。
擴展閱讀
Collection View Programming Guide.
UICollectionView: The Complete Guide, e-book by Ash Furrow.
MSCollectionViewCalendarLayoutby Eric Horacek is an excellent and more complete implementation of a custom layout for a week calendar view.