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