UICollectionView是開發中用的比較多的一個控制元件,本文記錄UICollectionView在開發中常用的方法總結,包括使用
UICollectionViewFlowLayout
實現Grid佈局、新增Header/Footer、自定義layout佈局、UICollectionView的其它方面比如新增Cell的點選效果等等
本文Demo: CollectionViewDemo
UICollectionView重要的概念
UICollectionView
中有幾個重要的概念,理解這幾個重要的概念對於使用UICollectionView
有很大的幫助,這個幾個概念從使用者的資料、佈局展示的資料、檢視展示的View、UICollectionView
充當的角色這幾個維度來展開講解,這部分講解的是偏概念的東西,如果你是一個實用主義者,那麼可以直接跳到下一部分“UICollectionView和UICollectionViewFlowLayout”檢視UICollectionView的簡單實用,然後再回過頭來回顧下這些概念,這樣也是一個比較好的方式
使用者的資料
使用者的資料是UICollectionView中的DataSource,DataSource告訴UICollectionView有幾個section、每個section中有幾個元素需要展示,這點和UITableView中的DataSource是類似的
佈局展示的資料
佈局展示的資料是UICollectionView中的Layout,Layout告訴UICollectionView每個section中元素展示的大小和位置,每個元素展示的位置大小資訊是儲存在一個UICollectionViewLayoutAttributes
類的物件中,Layout物件會管理一個陣列包含了多個UICollectionViewLayoutAttributes
的物件。Layout對應的具體類是UICollectionViewLayout
和UICollectionViewFlowLayout
,UICollectionViewFlowLayout
可以直接使用,最簡單的通過設定每個元素的大小就可以實現Grid佈局。如果需要更多了定製設定其他屬性比如minimumLineSpacing
、minimumInteritemSpacing
來設定元素之間的間距。
檢視展示的View
DataSource中每個資料展示需要使用到的是UICollectionViewCell
類物件,一般的通過建立UICollectionViewCell
的子類,新增需要的UI元素進行自定義的佈局。可以使用registerClass:forCellReuseIdentifier:
方法或者registerNib:forCellReuseIdentifier:
方法註冊,然後在UICollectionView的DataSource方法collectionView: cellForItemAtIndexPath:
中使用方法dequeueReusableCellWithIdentifier:
獲取到前面註冊的Cell,使用item設定急需要展示的資料。
另外如果有特殊的Header/Footer需求,需要使用到的是UICollectionReusableView
類,一般也是通過建立子類進行設定自定義的UI。可以使用registerClass:forSupplementaryViewOfKind:withReuseIdentifier:
方法或者registerNib:forSupplementaryViewOfKind:withReuseIdentifier:
方法註冊,然後在UICollectionView的DataSource方法collectionView: viewForSupplementaryElementOfKind: atIndexPath:
中使用方法dequeueReusableSupplementaryViewOfKind: withReuseIdentifier: forIndexPath:
獲取到前面註冊的reusableView,然後設定需要展示的資料。
UICollectionView充當的角色
UICollectionView在這裡面充當的角色是一個容器類,是一箇中間者,他用於連線DataSource、Layout、UI之間的關係,起到一個協調的作用,CollectionView的角色可以使用下面的這張圖來標識。

UICollectionView和UICollectionViewFlowLayout
UICollectionView已經為我們準備好了一個開箱即用的Layout類,就是UICollectionViewFlowLayout
,使用UICollectionViewFlowLayout
可以實現經常使用到的Grid表格佈局,下面瞭解下UICollectionViewFlowLayout
中常用的幾個屬性的意思以及如何使用和定製UICollectionViewFlowLayout
。
UICollectionViewFlowLayout
標頭檔案中定義的屬性如下:
@property (nonatomic) CGFloat minimumLineSpacing;
@property (nonatomic) CGFloat minimumInteritemSpacing;
@property (nonatomic) CGSize itemSize;
@property (nonatomic) UICollectionViewScrollDirection scrollDirection;
@property (nonatomic) UIEdgeInsets sectionInset;
複製程式碼
-
minimumLineSpacing 如果itemSize的大小是一樣的,那麼真實的LineSpacing就是minimumLineSpacing,如果高度不一樣,那麼這個值回事上一行中Y軸值最大者和當前行中Y軸值最小者之間得高度,行中其它元素的LineSpacing會大於minimumLineSpacing
 -
minimumInteritemSpacing 如下圖所示,定義的是元素水平之間的間距,這個間距會大於等於我們設定的值,因為有可能有可能一行容納不下只能容納下N個元素,還有M個單位的空間,這些剩餘的空間會被平局分配到元素的間距,那麼真實的IteritemSpacing值其實是(minimumInteritemSpacing + M / (N - 1))

-
itemSize itemSize表示的是Cell的大小
-
scrollDirection 如下圖所示,表示UICollectionView的滾動方向,可以設定垂直方向
UICollectionViewScrollDirectionVertical
和水平方向UICollectionViewScrollDirectionHorizontal
 -
sectionInset 定義的是Cell區域相對於UICollectionView區域的上下左右之間的內邊距,如下圖所示

在瞭解了UICollectionViewFlowLayout
的一些概念之後,我們實現一個如下的表格佈局效果

1. UICollectionViewFlowLayout初始化和UICollectionView的初始化
首先使用UICollectionViewFlowLayout物件初始化UICollectionView物件,UICollectionViewFlowLayout物件設定item元素顯示的大小,滾動方向,內邊距,行間距,元素間距,使得一行剛好顯示兩個元素,並且元素內邊距為5,元素的間距為10,行間距為20,也就是上圖的效果。 這邊還有一個重要的操作是使用registerClass:forCellWithReuseIdentifier:
方法註冊Cell,以備後面的使用。
- (UICollectionView *)collectionView {
if (_collectionView == nil) {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
CGFloat itemW = (SCREEN_WIDTH - 20) / 2;
CGFloat itemH = itemW * 256 / 180;
layout.itemSize = CGSizeMake(itemW, itemH);
layout.sectionInset = UIEdgeInsetsMake(5, 5, 5, 5);
layout.scrollDirection = UICollectionViewScrollDirectionVertical;
layout.minimumLineSpacing = 20;
layout.minimumInteritemSpacing = 10;
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectZero collectionViewLayout:layout];
_collectionView.backgroundColor = [UIColor whiteColor];
_collectionView.delegate = self;
_collectionView.dataSource = self;
[_collectionView registerClass:[TTQVideoListCell class] forCellWithReuseIdentifier:@"TTQVideoListCell"];
}
return _collectionView;
}
複製程式碼
2. UICollectionViewDataSource處理
- 重寫
collectionView: numberOfItemsInSection:
返回元素個數 - 重寫
collectionView: cellForItemAtIndexPath:
,使用dequeueReusableCellWithReuseIdentifier:
獲取重用的Cell,設定Cell的資料,返回Cell - 重寫
collectionView: didSelectItemAtIndexPath:
,處理Cell的點選事件,這一步是非必須的,但是絕大多數場景是需要互動的,點選Cell需要執行一些處理,所以這裡也新增上這個方法,在這裡做一個取消選擇狀態的處理
// MARK: - UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.dataSource.count;
}
- (__kindof UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
TTQVideoListCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"TTQVideoListCell" forIndexPath:indexPath];
TTQVideoListItemModel *data = self.dataSource[indexPath.item];
[cell setupData:data];
return cell;
}
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
TTQVideoListItemModel *data = self.dataSource[indexPath.item];
[collectionView deselectItemAtIndexPath:indexPath animated:YES];
// FIXME: ZYT 處理跳轉
}
複製程式碼
3.資料來源
資料來源是一個簡單的一維陣列,如下
- (NSMutableArray *)dataSource {
if (!_dataSource) {
_dataSource = [NSMutableArray array];
// FIXME: ZYT TEST
for (int i = 0; i < 10; i++) {
TTQVideoListItemModel *data = [TTQVideoListItemModel new];
data.images = @"https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1534329621698&di=60249b63257061ddc1f922bf55dfa0f4&imgtype=0&src=http%3A%2F%2Fimgsrc.baidu.com%2Fimgad%2Fpic%2Fitem%2Fd009b3de9c82d158e0bd1d998b0a19d8bc3e42de.jpg";
[_dataSource addObject:data];
}
}
return _dataSource;
}
複製程式碼
4.Cell實現
在這個演示專案中,Cell是通過程式碼的方式繼承UICollectionViewCell
實現的
標頭檔案:
@interface TTQVideoListCell : UICollectionViewCell
- (void)setupData:(TTQVideoListItemModel *)data;
@end
複製程式碼
實現檔案:
@interface TTQVideoListCell()
@property (nonatomic, strong) UIImageView *coverImageView;
@property (nonatomic, strong) UIView *titleLabelBgView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *playCountLabel;
@property (nonatomic, strong) UILabel *praiseCountLabel;
@property (nonatomic, strong) UILabel *statusLabel;
@property (nonatomic, strong) UILabel *tagLabel;
@property (nonatomic, strong) TTQVerticalGradientView *bottomGradientView;
@property (nonatomic, strong) TTQVerticalGradientView *topGradientView;
@property (strong, nonatomic) UIView *highlightView;
@end
@implementation TTQVideoListCell
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setupUI];
}
return self;
}
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
if (highlighted) {
self.highlightView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.5];
} else {
self.highlightView.backgroundColor = [UIColor colorWithWhite:0 alpha:0];
}
}
- (void)setupUI {
self.contentView.layer.cornerRadius = 4;
self.contentView.layer.masksToBounds = YES;
[self.contentView addSubview:self.coverImageView];
[self.contentView addSubview:self.topGradientView];
[self.contentView addSubview:self.bottomGradientView];
[self.contentView addSubview:self.titleLabelBgView];
[self.titleLabelBgView addSubview:self.titleLabel];
[self.contentView addSubview:self.playCountLabel];
[self.contentView addSubview:self.praiseCountLabel];
[self.contentView addSubview:self.statusLabel];
[self addSubview:self.tagLabel];
[self addSubview:self.highlightView];
// 佈局省略了,具體可以檢視git倉庫中的程式碼
}
- (void)setupData:(TTQVideoListItemModel *)data {
self.titleLabel.text = data.title;
self.playCountLabel.text = @"播放次數";
self.praiseCountLabel.text = @"點贊次數";
[self.coverImageView sd_setImageWithURL:[NSURL URLWithString:data.images]];
if (data.status == TTQVideoItemStatusReviewRecommend) {
self.tagLabel.hidden = NO;
self.statusLabel.hidden = YES;
self.tagLabel.text = data.status_desc;
} else {
self.tagLabel.hidden = YES;
self.statusLabel.hidden = NO;
self.statusLabel.text = data.status_desc;
}
}
複製程式碼
只要以上幾個步驟,我們就能實現一個Grid的表格佈局了,如果有其它的Header/Footer的需求,其實也只要增加三個小步驟就可以實現,下面就來實現一個帶有Header/Footer效果的CollectionView
UICollectionViewFlowLayout的Header和Footer
UICollectionView中的Header和Footer也是會經常使用到的,下面通過三個步驟來實現,這三個步驟其實和Cell的步驟是相似的,所以十分簡單

**1.註冊Header/Footer **
使用registerClass:forSupplementaryViewOfKind:withReuseIdentifier:
方法或者registerNib:forSupplementaryViewOfKind:withReuseIdentifier:
方法註冊
[_collectionView registerClass:SimpleCollectionHeaderView.class forSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:@"SimpleCollectionHeaderView"];
[_collectionView registerClass:SimpleCollectionFooterView.class forSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"SimpleCollectionFooterView"];
複製程式碼
**2.獲取Header/Footer **
- 重寫
collectionView: layout: referenceSizeForHeaderInSection:
返回header的高度 - 重寫
collectionView: layout: referenceSizeForFooterInSection:
返回footer的高度 - 重寫
collectionView: viewForSupplementaryElementOfKind: atIndexPath:
方法,使用方法dequeueReusableSupplementaryViewOfKind: withReuseIdentifier: forIndexPath:
獲取到前面註冊的reusableView,然後設定需要展示的資料。該方法中的kind引數可以使用UICollectionElementKindSectionHeader
、UICollectionElementKindSectionFooter
兩個常量來判斷是footer還是header
// MARK: 處理Header/Footer
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section {
return CGSizeMake(SCREEN_WIDTH, 40);
}
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section {
return CGSizeMake(SCREEN_WIDTH, 24);
}
- (UICollectionReusableView *)collectionView:(UICollectionView *)collectionView viewForSupplementaryElementOfKind:(NSString *)kind atIndexPath:(NSIndexPath *)indexPath {
UICollectionReusableView *supplementaryView = nil;
SectionDataModel *sectionData = self.dataSource[indexPath.section];
if ([kind isEqualToString:UICollectionElementKindSectionHeader]) {
SimpleCollectionHeaderView* header = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionHeader withReuseIdentifier:@"SimpleCollectionHeaderView" forIndexPath:indexPath];
header.descLabel.text = sectionData.title;
supplementaryView = header;
} else {
SimpleCollectionFooterView* footer = [collectionView dequeueReusableSupplementaryViewOfKind:UICollectionElementKindSectionFooter withReuseIdentifier:@"SimpleCollectionFooterView" forIndexPath:indexPath];
footer.descLabel.text = [NSString stringWithFormat:@"%@條資料", @(sectionData.items.count)];
supplementaryView = footer;
}
return supplementaryView;
}
複製程式碼
**3.Header/Footer類實現 **
繼承UICollectionReusableView類,然後進行自定義的UI佈局即可,下面實現一個簡單的Header,只有一個Label顯示分類的標題,注意需要使用UICollectionReusableView子類,才能利用CollectionView中的重用機制
標頭檔案
@interface SimpleCollectionHeaderView : UICollectionReusableView
@property (nonatomic, strong) UILabel *descLabel;
@end
複製程式碼
實現檔案
@implementation SimpleCollectionHeaderView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self addSubview:self.descLabel];
self.backgroundColor = [UIColor colorWithWhite:0.95 alpha:0.6];;
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
self.descLabel.frame = CGRectMake(15, 0, self.bounds.size.width - 30, self.bounds.size.height);
}
- (UILabel *)descLabel {
if (!_descLabel) {
_descLabel = [UILabel new];
_descLabel.font = [UIFont systemFontOfSize:18];
_descLabel.textColor = [UIColor colorWithWhite:0.7 alpha:1];
}
return _descLabel;
}
@end
複製程式碼
自定義Layout
自定義Layout為CollectionView的佈局提供了最大的靈活性,使用自定義的Layout可以實現複雜的佈局檢視,下面會通過一個簡單的例子來了解下自定義Layout,更加深入的內容可以檢視ClassHierarchicalTree這個開源專案的程式碼進行學習,Demo專案中自定義佈局實現的效果如下:

自定義Layout需要經過以下的幾個步驟
- 預處理,該步驟是可選的,為了提高效能可以在這個方法中做預處理
- 提供ContentSize
- 提供LayoutAttributes,是一個陣列,表示的是在UICollectionView可見範圍內的item顯示的Cell的佈局引數
- 提供單獨的Attributes,與IndexPath相關的的佈局引數
作為一個最簡單的實踐,本文不做預處理,所以步驟只有後面三個,接下來逐個的展開來說
下面的程式碼中會使用到下面的幾個巨集定義的值得意思說明如下:
/**
Cell外邊距
*/
#define VideoListCellMargin 5
/**
Cell寬度
*/
#define VideoListCellWidth ((SCREEN_WIDTH - VideoListCellMargin * 3) / 2)
/**
Cell高度
*/
#define VideoListCellHeight (VideoListCellWidth * 265 / 180)
複製程式碼
下面的程式碼中會使用到headerHeight
表示的是頭部檢視的高度,datas
表示的是資料來源
@interface TTQVideoListLayout : UICollectionViewLayout
@property (nonatomic, strong) NSArray<TTQVideoListItemModel *> *datas;
/**
頭部檢視的高度
*/
@property (nonatomic, assign) CGFloat headerHeight;
@end
複製程式碼
提供ContentSize
ContentSize的概念和ScrollView中contentSize的概念類似,表示的是所有內容佔用的大小,下面的程式碼會根據DataSource陣列的大小和headerHeight的值計算最終需要顯示的大小
- (CGSize)collectionViewContentSize {
return CGSizeMake(SCREEN_WIDTH, ceil((CGFloat)self.datas.count / (CGFloat)2) * (VideoListCellHeight + VideoListCellMargin) + self.headerHeight + VideoListCellMargin);
}
複製程式碼
提供LayoutAttributes
返回值是一個陣列,表示的是在UICollectionView可見範圍內的item顯示的Cell的佈局引數,如下圖的Visible rect標識的位置中所有元素的佈局屬性

實現的方式很簡單,通過對全部內容的佈局屬性的遍歷,判斷是否和顯示區域的rect有交集,如果有交集,就把該佈局屬性物件新增到陣列中,最後返回這個陣列。
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
NSMutableArray *array = [[NSMutableArray alloc] init];
for (NSInteger i = 0; i < self.datas.count; i++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:0];
UICollectionViewLayoutAttributes *attributes = [self layoutAttributesForItemAtIndexPath:indexPath];
if (!CGRectEqualToRect(attributes.frame, CGRectZero)) {
if (CGRectIntersectsRect(rect, attributes.frame)) {
[array addObject:attributes];
}
}
}
return array;
}
複製程式碼
提供單獨的Attributes
這個方法用於返回和單獨的IndexPath相關的佈局屬性物件,根據indexPath中的row引數可以知道元素的位置,然後可以計算出相應所在的位置大小,然後初始化一個UICollectionViewLayoutAttributes物件,設定引數值,返回UICollectionViewLayoutAttributes物件即可
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewLayoutAttributes* attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
if (indexPath.row < self.datas.count) {
id item = self.datas[indexPath.row];
if ([item isKindOfClass:[TTQVideoListItemModel class]]) {
CGFloat originX = (indexPath.row % 2 == 0) ? (VideoListCellMargin) : (VideoListCellMargin * 2 + VideoListCellWidth);
CGFloat originY = indexPath.row/ 2 * (VideoListCellMargin + VideoListCellHeight) + VideoListCellMargin + self.headerHeight;
attributes.frame = CGRectMake(originX, originY, VideoListCellWidth, VideoListCellHeight);
} else {
attributes.frame = CGRectZero;
}
} else {
attributes.frame = CGRectZero;
}
return attributes;
}
複製程式碼
其它
Cell點選效果是很經常使用到的,這邊主要講下兩種Cell點選效果的實現方式
Cell點選效果
有兩種方法可以實現CollectionViewCell的點選效果,一種是設定CollectionViewCell
的屬性selectedBackgroundView
和backgroundView
;另一種是重寫setHighlighted
方法設定自定義的背景View的高亮狀態
設定selectedBackgroundView和backgroundView
下圖中的左邊是點選效果,右邊是普通的狀態
UIView *selectedBackgroundView = [UIView new];
selectedBackgroundView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.5];
self.selectedBackgroundView = selectedBackgroundView;
UIView *backgroundView = [UIView new];
backgroundView.backgroundColor = [UIColor clearColor];
self.backgroundView = backgroundView;
複製程式碼

這種方式有一個侷限性,如下圖所示,設定的selectedBackgroundView
和backgroundView
是位於Cell的最底層,如果上面有自定義的圖層會覆蓋住selectedBackgroundView
和backgroundView
,比如Cell中設定了一個充滿Cell檢視的ImageView,點選的效果將會不可見。

重寫setHighlighted方法
重寫setHighlighted
方法相對來說是一種靈活性比較高的方法,這種方式和自定義UITableViewCell的高亮狀態很類似,setHighlighted
方法中通過判斷不同的狀態進行設定任意的UI元素的樣式,我們可以在Cell的最上層新增一個自定義的高亮狀態的View,這樣高亮的效果就不會因為充滿Cell的UI而導致看不見了,程式碼如下
- (void)setupUI {
// ......
[self addSubview:self.highlightView];
[self.highlightView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self);
}];
}
- (UIView *)highlightView {
if (!_highlightView) {
_highlightView = [UIView new];
_highlightView.backgroundColor = [UIColor clearColor];
_highlightView.layer.cornerRadius = 3;
}
return _highlightView;
}
- (void)setHighlighted:(BOOL)highlighted {
[super setHighlighted:highlighted];
if (highlighted) {
self.highlightView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.5];
} else {
self.highlightView.backgroundColor = [UIColor colorWithWhite:0 alpha:0];
}
}
複製程式碼
效果如下圖:
參考
Collection View Programming Guide for iOS
自定義 Collection View 佈局