objc系列譯文(3.3):自定義Collection View佈局

發表於2014-01-17

UICollectionView在iOS6中第一次被介紹,也是UIKit檢視類中的一顆新星。它和UITableView共享API設計,但也在UITableView上做了一些擴充套件。UICollectionView最強大、同時顯著超出UITableView的特色就是其完全靈活的佈局結構。在這篇文章中,我們將會實現一個相當複雜的自定義collection view佈局,並且順便討論一下這個類設計的重要部分。專案的示例程式碼在GitHub上。

佈局物件

UITableView和UICollectionView都是由data-source和delegate驅動的。他們為其顯示的子檢視集扮演為愚蠢的容器(dumb containers),對他們真實的內容(contents)毫不知情。

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的cells必須是UICollectionViewCell的子類。除了cells,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純粹為一個裝飾品。他們完全屬於佈局物件,並被佈局物件管理,他們並不從資料來源獲取他們的contents。當佈局物件指定它需要一個decoration view的時候,collection view會自動建立,併為其應用佈局物件提供的佈局引數。並不需要準備任何自定義檢視的內容。

Supplementary views和decoration views必須是UICollectionResuableView的子類。每個你佈局所使用的檢視類都需要在collection view中註冊,這樣當data source讓他從reuse pool中出列時,它才能夠建立新的例項。如果你是使用的Interface Builder,則可以通過在可視編輯器中拖拽一個cell到collection view上完成cell在collection view中的註冊。同樣的方法也可以用在supplementary view上,前提是你使用了UICollectionViewFlowLayout。如果沒有,你只能通過呼叫registerClass:或者registerNib:方法手動註冊檢視類了。你需要在viewDidLoad中做這些操作。

 120140117114206

自定義佈局

作為一個非常有意義的自定義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表示。在一個真實的日曆程式中,佈局將會為自己的計算大量使用基於NSCalendar的日期。

layoutAttributesForElementsInRect:

這是任何佈局類中最重要的方法了,同時可能也是最容易讓人迷惑的方法。collection view呼叫這個方法並傳遞一個自身座標系統中的矩形過去。這個矩形代表了這個檢視的可見矩形區域(也就是它的bounds),你需要準備好處理傳給你的任何矩形。

你的實現必須返回一個包含UICollectionViewLayoutAttributes物件的陣列,為每一個cell包含這樣的一個物件,supplementary view或decoration view在矩形區域內是可見的。UICollectionViewLayoutAttributes類包含了collection view內item的所有相關佈局屬性。預設情況下,這個類包含frame,center,size,transform3D,alpha,zIndex屬性(properties),和hidden特性(attributes)。如果你的佈局想要控制其他檢視的屬性(比如,背景顏色),你可以建一個UICollectionViewLayoutAttributes的子類,然後加上你自己的屬性。

佈局屬性物件通過indexPath屬性和他們對應的cell,supplementary view或者decoration view關聯在一起。collection view為所有items從佈局物件中請求到佈局屬性後,它將會例項化所有檢視,並將對應的屬性應用到每個檢視上去。

注意!這個方法涉及到所有型別的檢視,也就是cell,supplementary views和decoration views。一個幼稚的實現可能會選擇忽略傳入的矩形,並且為collection view中的所有檢視返回佈局屬性。在原型設計和開釋出局階段,這是一個有效的方法。但是,這將對效能產生非常壞的影響,特別是可見cell遠少於所有cell數量的時候,collection view和佈局物件將會為那些不可見的檢視做額外不必要的工作。

你的實現需要做這幾步:

1.建立一個空的mutable陣列來存放所有的佈局屬性。

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)

有時,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: method。這樣我們便能比較檢視當前的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。

相關文章