前言:本來我昨天遇到一個需要動態調整 UICollectionViewCell 尺寸和布局的需求,自己手動實現了,想起來去年 iOS 8 中的 Self-sizing cells,于是過來學習一下。發現這個新特性與我的需求不怎么搭配,但是這是個很有意思并且很實用的新特性。
首先來看看應用場景:
下面兩種布局要怎么實現?要按以往的方法,前者需要在 delegate 中的– tableView:heightForRowAtIndexPath:
根據 UILabel 的內容來計算每個 cell 的高度(此話有點不對,因為這個方法實在那個 cellForXXX 的方法前調用的,不知道具體內容是沒法計算的,解決方案可以參考這篇博客),后者需要自定義布局來計算每個 cell 的大小,兩者都比較麻煩,而且很容易造成性能低下,特別是后者。但是利用新特性,在設定了相應的約束的前提下,僅僅需要幾行代碼就可以搞定,準確來說,前者三行代碼,后者僅需一行代碼,還避免老方法的性能缺陷,酷到沒朋友。另外,上面的 tableview 中的字體是動態變化的,只要用戶在設置中更改字體大小,這里也會做出相應的調整,是不是很方便。這也是 iOS 8 中的新特性。
筆記內容
- 動態類型適應(動態字體, TableView 以及 CollectionView 都適用)
- Self-sizing cells(兩者都適用)
- CollectionView 智能布局更新
1. 動態類型適應(Dynamic Type adoption)
在 iOS 8 中允許應用使用動態字體,在通用-輔助功能-更大字體的設置中,可以為支持動態字體的應用設置字體大小了。而在支持動態字體的應用中,TableView 中使用了動態字體的地方將會根據字體的大小來自動調整大小和布局,真是很方便,主講工程師也推薦大家使用動態字體。工程師并未提及 CollectionView 支持 Dynamic Type adoption,根據我的試驗,CollectionView 也是支持的,但是沒有 TableView 中那么完美,不像后者在在設置中調整好后再次進入應用即可調整,前者需要殺掉應用再次進入應用才會調整大小,嚴格來說不是 Dynamic Type adoption。
支持 Dynamic Type adoption 的前提是使用預定義的字體樣式,也就是說你使用了其他的字體是不支持動態適應的。有兩者方法可以設置:
1.在代碼中通過 [UIFont preferredFontForTextStyle:]
來設定;
2.在 IB 中選擇預定義字體樣式。
預定義的字體支持六種樣式:
NSString *const UIFontTextStyleHeadline;
NSString *const UIFontTextStyleSubheadline;
NSString *const UIFontTextStyleBody;
NSString *const UIFontTextStyleFootnote;
NSString *const UIFontTextStyleCaption1;
NSString *const UIFontTextStyleCaption2;
2. Self-sizing Cells
1) TableView
在 TableView 中有兩種手法來調整每一行的高度:
1.property:rowHeight
;
2.delegate: - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
第一個方法只能將所有行設置為同一高度;第二個方法有很大的性能缺陷,因為 TableView 每次生成 cell 的時候都要向 delegate 詢問這個方法。
新特性 Self-sizing Cells 的實現代碼擁有良好的性能,且僅需幾行代碼:
self.tableView.estimatedRowHeight = 44.0f;
self.tableView.rowHeight = UITableViewAutomaticDimension;
而要實現開頭圖中的布局,還需要對 UILabel 進行設置:
label.numberOfLines = 0//使UILabel 的高度根據其內容來變化`
estimatedRowHeight
是 iOS 7 中新增的屬性,顧名思義,預計高度;同時需要將 rowHeight 屬性設置為 UITableViewAutomaticDimension
,意思是告訴 TableView 沒有 rowHeight,請根據其他信息來判斷每一行的高度。另外,TableView 還有 HeaderView 和 FooterView,也有類似的屬性來達到同樣的效果。
實現 Self-sizing Cells 的關鍵屬性:estimatedRowHeight
。當滾動 TableView 使得 cell 即將顯示在屏幕上的時候,生成一個 cell,cell 的高度根據 estimatedRowHeight
或者 delegate 的-tableView: heightForRowAtIndexPath:
返回的高度來決定。使用 Self-sizing Cells 時,則是根據 estimatedRowHeight 來決定(此時在 delegate 中不要實現對應的方法),當生成了 cell 后,向 cell 詢問它到底應該有多高(在下面講解實現機制),如果結果和 estimatedRowHeight
不一樣,則調整 TableView 的 contentSize
,最后將 cell 顯示在屏幕上。
怎么知道 cell 到底應該有多高呢?有兩種方法可以知道,而這也是實現 Self-sizing Cells 的前提條件:
根據 contentView 中的約束,TableView 向 cell 發送 -systemLayoutSizeFittingSize:
消息來得到它應該具備的高度;如果你沒有添加約束,還有一個機會來知道這個高度,systemLayoutSizeFittingSize:
會調用 - (CGSize)sizeThatFits:(CGSize)size
,這時候就需要覆寫該方法來手工計算了。
2) CollectionView
從這個部分開始是另外一個工程師講的,應該是一位印度工程師,那口音,以后我搞英文演講也不是夢了。
TableView 中 cell 的寬度是相對而言是個固定值,高度是可變的,而 CollectionView 則需要兩個方向的尺寸才能工作。在 CollectionView 中,所有 cells 和 supplementary view 以及 decoration view 的尺寸、位置以及其他布局屬性都是由 CollectionView 的 layout 對象決定。默認情況下,CollectionView 采用FlowLayout 布局,與 TableView 的 estimatedRowHeight
屬性對應的是 UICollectionViewFlowLayout
中新的 estimatedItemSize
屬性。
1)FlowLayout
在 CollectionView 中使用 Self-sizing Cells 的前提條件同 TableView 類似,對 cell 的 contentView 添加約束條件或是重寫 - (CGSize)sizeThatFits:(CGSize)size
,如果采用后者,則還會遇到 rotation 的問題,比較麻煩,推薦使用約束。注意,如果約束條件并不是非常嚴格,動態尺寸布局就不會那么完整,比如,在開頭的布局中,只對寬度進行約束的話,那么在高度方面就不會進行動態適應了。
在 CollectionView 中使用 Self-sizing Cells,只需要將 FlowLayout 的 estimatedItemSize
指定為非零尺寸即可。一行代碼搞定,在- viewDidLoad:
中:
UICollectionViewFlowLayout *selfFlowLayout = (UICollectionViewFlowLayout *)self.collectionViewLayout;
selfFlowLayout.estimatedItemSize = CGSizeMake(50, 50);
好吧,兩行。具體的 cell 的尺寸則會根據約束條件以及 estimatedItemSize
綜合來考慮。
- 自定義 layout
如果不使用 FlowLayout 或者其子類而自定義其他布局的話,也可以使用 Self-sizing cells,需要重寫以下方法,這些是 iOS8 新增的方法:- shouldInvalidateLayoutForPreferredLayoutAttributes:withOriginalAttributes:
- invalidationContextForPreferredLayoutAttributes:withOriginalAttributes:
其中的 PreferredLayoutAttributes 其實是指 UICollectionReusableView 中的新增方法: - preferredLayoutAttributesFittingAttributes:
該方法使得 cell 有機會在應用 layout 返回的布局前做出最后一次修改。
實際上 CollectionView 的 Self-sizing Cells 與UICollectionViewLayoutInvalidationContext
類有較大關系,這就引出下面了的內容。
CollectionView 的智能布局更新
iOS 8 為 CollectionView 提供了更細節化而且更全面的布局控制,對于提升自定義布局的性能很有幫助。
1)布局策略
上面的圖本來是視頻中工程師講述 CollectionView 中 Self-sizing Cells 的截圖,放在這里是想說明 cell 的布局是如何驅動的。圖中第一種方法就是在 iOS 6 中隨著 UICollectionView
一起推出的 UICollectionViewLayout
類,它決定了 cell 以及 SupplementaryView 的位置、大小、alpha以及其他布局信息,是以往實現自定義布局的唯一選擇;Self-sizing Cells 就是今天講到的新特性,利用約束條件或是重寫 - sizeThatFits:
結合新增的 estimatedItemSize
屬性實現動態布局;第三種則是利用了 cell 的父類 UICollectionReusableView
的新增方法
- preferredLayoutAttributesFittingAttributes:
在應用 Self-sizing Cells 的布局信息前最后一次修改布局信息。后面兩種都是 iOS 8 中的新特性:
FlowLayout 中的 estimatedItemSize
在 Self-sizing Cells 中起到了什么作用呢?與 TableView 中的 estimatedRowHeight
類似,只是由于UICollectionView
多了布局對象以及 cell 多出了一個維度的尺寸變化,estimatedItemSize
參與的布局過程比 estimatedRowHeight
在 TableView 中更加復雜了。首先由 CollectionView 的 FlowLayout 對象根據 estimatedItemSize
以及其他信息計算出 cell 的布局信息,CollectionView 根據數據源重用或生成 cell,Self-sizing Cells 機制在這里發揮作用,實現手法和前面提到的一樣:利用 AutoLayout 或是 -sizeThatFits:
;或者由 - preferredLayoutAttributesFittingAttributes:
做出最終修改,CollectionView 將更新的布局信息反饋給 FlowLayout 對象,后者響應更新的布局信息,最后向 CollectionView 提供最終的布局信息將 cell 顯示在屏幕上。
2)布局更新(Layout Invalidation)
I) 傳統的布局更新
UICollectionViewLayout
使用-invalidateLayout
來更新布局,布局對象會依次調用以下方法:
- prepareLayout
- collectionViewContentSize
- layoutAttributesForElementsInRect:
再次更新布局時,上面的方法再循環一次。在 iOS 8 之前,更新布局只能對 CollectionView 上的所有 elements 進行布局更新。顯然,這里面有很多本不需要進行計算的,也是性能隱患;從 iOS 8 開始可以針對局部的布局進行更新了,不需要重新計算所有 elements 的布局信息,這對性能的提升有很大的幫助,主講工程師稱之為「Smart Invalidation」。
II) 新的布局系統
實現智能布局更新的關鍵在于 UICollectionViewLayoutInvalidationContext
類,這并不是 iOS 8 才推出的,而是在 iOS 7 中出現的。InvalidationContext 類用來提供布局更新的信息,但在 iOS 7 中只是用來重構原來的布局系統,并沒有提供新的特性。
在 iOS 7 中 InvalidationContext 類僅有兩個公開接口, 而且調用者無法設置;在添加或是刪除 items 時,由 CollectionView 來自動設置:
//指示是否有增減 element
@property (nonatomic, readonly) BOOL invalidateDataSourceCounts;
//指示是否需要更新全部的布局信息
@property (nonatomic, readonly) BOOL invalidateEverything;
為了搭建新的布局系統,在 iOS 7 中 UICollectionViewLayout
類新增了三個方法,用來配合 InvalidationContext 類使用:
+ invalidationContextClass //指定布局環境
- invalidateLayoutWithContext://根據提供的布局環境來更新布局`
對 bounds 變化時的支持:
- invalidationContextForBoundsChange://返回一個包含了所需信息的InvalidationContext對象
FlowLayout 在處理 rotation 時使用了 InvalidationContext 來更新布局,流程如下:
//首先詢問是否需要更新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds
//如果需要更新布局,接下來調用下面的方法獲取InvalidationContext對象,如果重寫下面的方法,需要調用父類的實現
- (UICollectionViewLayoutInvalidationContext *)invalidationContextForBoundsChange:(CGRect)newBounds
//最后利用上面獲取的InvalidationContext 來更新布局
- (void)invalidateLayoutWithContext:(UICollectionViewLayoutInvalidationContext *)context
Note: 在 FlowLayout 中使用 Self-sizing Cells 來實現文章開頭的布局的時候,其處理 rotation 的性能相當的差。在我的 iPad mini 上,要經過3秒左右才能響應屏幕的旋轉。說好的提升性能呢,不知道是哪里出了問題,不然也不至于把這個特性放出來。在不使用 Self-sizing 的時候,稍好一點,不知道是不是字符串的處理有性能缺陷。也有可能我的機器太老了。
III) 智能布局更新
有了 iOS 7 中打下的基礎,iOS 8 在 InvalidationContext 類中增加了新的 API,可以對針對局部的布局更新了:
- invalidateItemsAtIndexPaths:
- invalidateSupplementaryElementsOfKind:atIndexPaths:
- invalidateDecorationElementsOfKind:atIndexPaths:
新的 API 和 CollectionView 更新 cell 內容的 API 類似,只是控制的尺度更細微,能夠具體到單個的 element 的布局,在以往更新布局只能對整體進行操作。利用新的 API,實現 Photos 應用中時間線里面的浮動 Header 就相當簡單了,調用 - invalidateSupplementaryElementsOfKind:atIndexPaths:
使得對應位置的 HeaderView 布局失效即可。
Note:說得好聽,但目前為止我還沒有利用這個特性成功地實現這個浮動 Header,一般稱之為 sticky header。目前我用 google 搜出來的答案基本抄的這個答案下的代碼。而這個嘗試利用 InvalidContext 的帖子,特別是那個meelawsh的回答,經試驗完全是鬼扯好吧。可是我對這個帖子沒有任何權限,既不能贊同反對,甚至不能評論!!!而且那個家伙甚至連個郵箱都沒有留下!
5/12 更新:被這個東西折騰了幾天,發現用這個來提升性能比寫一個浮動 header 布局麻煩多了。需要考慮屏幕上的 HeaderView 是否超出了屏幕從而單獨更新這個 HeaderView 的布局,以及下方的 HeaderView 上升到當前懸浮的 HeaderView 的位置時。
IV) 對 Self-sizing Cells 的支持
上面的章節里提到在自定義 layout 中使用 Self-sizing Cells,需要重寫以下兩個方法:
- shouldInvalidateLayoutForPreferredLayoutAttributes:withOriginalAttributes:
- invalidationContextForPreferredLayoutAttributes:withOriginalAttributes:
在第二個方法中,返回一個 InvalidationContext 對象。在 iOS 8 中 InvalidationContext 類新增了兩個屬性:
@property(nonatomic) CGPoint contentOffsetAdjustment;
@property(nonatomic) CGPoint contentSizeAdjustment
顧名思義,cell 的尺寸發生變化,那么在 UICollectionView
的 contentOffset
和 contentSize
都要發生變化。后者很好理解,cell 變大變小,需要用來展示內容的面積也回發生變化,前者呢,可以看看這篇文章:理解 ScrollView。在第二個方法返回的 InvalidationContext 對象需要提供這兩個信息。