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