自定義 Collection View 佈局
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
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
,所以這正是我們需要做的事情。
collectionViewContentSize
由於 collection view 對它的 content 並不知情,所以佈局首先要提供的資訊就是滾動區域大小,這樣 collection view 才能正確的管理滾動。佈局物件必須在此時計算它內容的總大小,包括 supplementary views 和 decoration views。注意,儘管大多數經典的 collection view 限制在一個軸方向上滾動(正如 UICollectionViewFlowLayout
一樣),但這不是必須的。
在我們的日曆示例中,我們想要檢視垂直的滾動。比如,如果我們想要在垂直空間上一個小時佔去 100 點,這樣顯示一整天的內容高度就是 2400 點。注意,我們不能夠水平滾動,這就意味這我們 collection view 只能顯示一週。為了能夠在日曆中的多個星期間分頁,我們可以在一個獨立(分頁)的 scroll view (可以使用 UIPageViewController)中使用多個collection view(一週一個),或者堅持使用一個 collection view 並且返回足夠大的內容寬度,這會使得使用者感覺在兩個方向上滑動自由。
- (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;
}
為了清楚起見,我選擇佈局在一個非常簡單的模型上:假定每週天數相同,每天時長相同,也就是說天數用 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 和佈局物件將會為那些不可見的檢視做額外不必要的工作。
你的實現需要做這幾步:
建立一個空的可變陣列來存放所有的佈局屬性。
確定 index paths 中哪些 cells 的 frame 完全或部分位於矩形中。這個計算需要你從 collection view 的資料來源中取出你需要顯示的資料。然後在迴圈中呼叫你實現的
layoutAttributesForItemAtIndexPath:
方法為每個 index path 建立並配置一個合適的佈局屬性物件,並將每個物件新增到陣列中。如果你的佈局包含 supplementary views,計算矩形內可見 supplementary view 的 index paths。在迴圈中呼叫你實現的
layoutAttributesForSupplementaryViewOfKind:atIndexPath:
,並且將這些物件加到陣列中。通過為 kind 引數傳遞你選擇的不同字元,你可以區分出不同種類的supplementary views(比如headers和footers)。當需要建立檢視時,collection view 會將 kind 字元傳回到你的資料來源。記住 supplementary 和 decoration views 的數量和種類完全由佈局控制。你不會受到 headers 和 footers 的限制。如果佈局包含 decoration views,計算矩形內可見 decoration views 的 index paths。在迴圈中呼叫你實現的
layoutAttributesForDecorationViewOfKind:atIndexPath:
,並且將這些物件加到陣列中。返回陣列。
我們自定義的佈局沒有使用 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;
}
layoutAttributesFor…IndexPath
有時,collection view 會為某個特殊的 cell,supplementary 或者 decoration view 向佈局物件請求佈局屬性,而非所有可見的物件。這就是當其他三個方法開始起作用時,你實現的 layoutAttributesForItemAtIndexPath:
需要建立並返回一個單獨的佈局屬性物件,這樣才能正確的格式化傳給你的 index path 所對應的 cell。
你可以通過呼叫 +[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:]
這個方法,然後根據 index path 修改屬性。為了得到需要顯示在這個 index path 內的資料,你可能需要訪問 collection view 的資料來源。到目前為止,至少確保設定了 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;
}
如果你正在使用自動佈局,你可能會感到驚訝,我們正在直接修改佈局引數的 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 來確定返回值:
- (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 的佈局是完全可變的,那麼預先定義好的動畫就沒辦法和開發者自定義的佈局很好的融合。他們提出了一個優雅的方法:當一個 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
。