UITableViewCell 中嵌套 UICollectionView 的实现

前言

自从去年来到花瓣实习以后,因为时间与精力(其实就是懒)的原因,这半年多没有写过一篇技术相关的文章。不过,随着项目的逐渐进展,我想,有必要对一些知识点进行总结与回顾了。与此同时,如果能帮助到大家就最好不过了:)

本文中的参考 Demo:https://github.com/tangqi92/QTTableCollectionView,请结合 Demo 阅读本文。


需求

这是 花瓣 4.0 中「个人主页」里的「关注」页面,我想,你一定会非常熟悉这样的界面布局:竖向与横向滚动列表的结合,我们熟悉的 App Store,其首页便是代表之一。


分析

本文的实现方式是:UITableView + UICollectionView 的结合(其实后来发现,仅仅使用 UICollectionView 就可以实现,不管啦~)。

于是,我们将页面简化,可以得到下图:

现在,我们来分析下 Layout 结构:最外层当然是 UITableView,然后其中每个 UITableViewCell 中包含着一个 UICollectionView,而其中的 UICollectionViewCell 有 3 种不同的样式(既红、绿、蓝三种样式)。于是,我们又得到了下图:

没错,我们将 UITableView 与 UICollectionView 的 Delegate 与 DataSource 都由 TableViewController 处理,是希望 UITableViewCell 中不要出现不必要的代码。


实现

下面,我们以 https://github.com/tangqi92/QTTableCollectionView 为例进行讲解。首先,来看下项目的目录结构:

Model

1
2
3
4
5
@interface QTExploreModel : NSObject

// TODO: 请根据自身需求,定义相关 Model 类与其属性。

@end

QTExploreModel \ QTBoardModel \ QTUserModel 3 个 Model 类,在这里仅是象征性作用,请根据实际项目需求定义。

View

自定义 UICollectionViewCell

1
2
3
4
5
6
7
@interface QTExploresCollectionViewCell : UICollectionViewCell

@property (nonatomic, strong) QTExploreModel *exploreModel;
/// 该方仅法用于测试。
- (void)setupModel;

@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@interface QTExploresCollectionViewCell ()

// TODO: 请根据自身需求,自定义 CollectionViewCell。
@property (nonatomic, strong) UIView *coverView;

@end

@implementation QTExploresCollectionViewCell

- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self initViews];
self.layer.cornerRadius = 4;
self.layer.masksToBounds = YES;
self.backgroundColor = [UIColor clearColor];
}
return self;
}

- (void)initViews {
_coverView = [UIView new];
[self.contentView addSubview:_coverView];
}

- (void)layoutSubviews {
[super layoutSubviews];
[_coverView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
}

- (void)setExploreModel:(QTExploreModel *)exploreModel {
// TODO: 数据填充
}

- (void)setupModel {
self.coverView.backgroundColor = [UIColor redColor];
}

@end

QTExploresCollectionViewCell \ QTBoardsCollectionViewCell \ QTUsersCollectionViewCell 3 个自定义 UICollectionViewCell,也请根据实际项目需求自行定义。

自定义 UICollectionView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// 3 种类型的 Cell。
typedef NS_ENUM(NSInteger, CollectionViewCellType) {
CellTypeExplores = 0,
CellTypeBoards,
CellTypeUsers
};

@interface QTCollectionView : UICollectionView

/// indexPath 用于查询相应的 Model,并填充至 Cell。
@property (nonatomic, strong) NSIndexPath *indexPath;
@property (nonatomic, assign) CollectionViewCellType collectionViewCellType;

@end

indexPath 用于记录 UICollectionView 所处的位置,而 CollectionViewCellType 用于区分不同类型的 UICollectionViewCell(当然,你可以直接使用 indexPath.section(0、1、2) 用以区别,只是我个人比较倾向使用枚举而已)。

自定义 UITableViewCell

1
2
3
4
5
6
7
8
9
10
11
12
13
static NSString *const ExploreCollectionViewCellID = @"ExploreCollectionViewCellID";
static NSString *const BoardCollectionViewCellID = @"BoardCollectionViewCellID";
static NSString *const UserCollectionViewCellID = @"UserCollectionViewCellID";

@interface QTTableViewCell : UITableViewCell

/// UITableViewCell 中嵌套 CollectionView。
@property (nonatomic, strong) QTCollectionView *collectionView;

/// 设置 CollectionView 的 DataSource 与 Delegate。
- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath;

@end

本文前面已经提过,该界面实现的关键便在于 UITableViewCell 中嵌套 UICollectionView,并将 UICollectionView 的 Delegate 与 DataSource 交由 UITableViewController 处理。在这里,我们使用 - (void)setCollectionViewDataSourceDelegate:indexPath: 处理。下面,我们再来看下 QTTableViewCell 的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@implementation QTTableViewCell

- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (!(self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) return nil;

// 使用 UICollectionViewFlowLayout 进行布局。
// 注册 UICollectionViewCell。
// 其他初始化操作。
......

return self;
}

- (void)layoutSubviews {
[super layoutSubviews];
[self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self.contentView);
}];
}

- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath {
self.collectionView.dataSource = dataSourceDelegate;
self.collectionView.delegate = dataSourceDelegate;
self.collectionView.indexPath = indexPath;
[self.collectionView setContentOffset:self.collectionView.contentOffset animated:NO];
[self.collectionView reloadData];
}

@end

Controller

UITableViewController 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- (void)tableView:(UITableView *)tableView willDisplayCell:(QTTableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
// 为 TableViewCell 中的 CollectionView 设置不同的 collectionViewCellType 用以区别,此处一共 3 种样式。
if (indexPath.section == 0) {
cell.collectionView.collectionViewCellType = CellTypeExplores;
NSInteger index = cell.collectionView.indexPath.row;
CGFloat horizontalOffset = [self.contentOffsetDictionaryOfExplores[[@(index) stringValue]] floatValue];
// 设置 CollectionView 的 ContentOffset,在'- (void)scrollViewDidScroll:(UIScrollView *)scrollView;' 中存储的。
[cell.collectionView setContentOffset:CGPointMake(horizontalOffset, 0)];
} else if (indexPath.section == 1) {
cell.collectionView.collectionViewCellType = CellTypeBoards;
NSInteger index = cell.collectionView.indexPath.row;
CGFloat horizontalOffset = [self.contentOffsetDictionaryOfBoards[[@(index) stringValue]] floatValue];
// 设置 CollectionView 的 ContentOffset。
[cell.collectionView setContentOffset:CGPointMake(horizontalOffset, 0)];
} else if (indexPath.section == 2) {
cell.collectionView.collectionViewCellType = CellTypeUsers;
NSInteger index = cell.collectionView.indexPath.row;
CGFloat horizontalOffset = [self.contentOffsetDictionaryOfUsers[[@(index) stringValue]] floatValue];
// 设置 CollectionView 的 ContentOffset。
[cell.collectionView setContentOffset:CGPointMake(horizontalOffset, 0)];
}

// !!!: 设置 CollectionView 的 DataSource 与 Delegate 及所处的 indexPath。
[cell setCollectionViewDataSourceDelegate:self indexPath:indexPath];
}

记住,在 UITableViewCell 中的 CollectionView 是在 Cell 之后初始化的,也就是在初始化这个 Cell 的之后要立即设置这个 Cell 中 CollectionView 的数据源跟代理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (UICollectionViewCell *)collectionView:(QTCollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewCell *cell = nil;

if (collectionView.collectionViewCellType == CellTypeExplores) {
cell = [collectionView dequeueReusableCellWithReuseIdentifier:ExploreCollectionViewCellID forIndexPath:indexPath];
if ([cell isKindOfClass:[QTExploresCollectionViewCell class]]) {
QTExploresCollectionViewCell *cellExplore = (QTExploresCollectionViewCell *) cell;
[cellExplore setupModel];
// TODO: 获取到相应的 Model 后进行赋值操作
// if (self.dataSourceExplores.count > 0) {
// cellExplore.exploreModel = self.dataSourceExplores[indexPath.row];
// }
}
} else if (collectionView.collectionViewCellType == CellTypeBoards) {
cell = [collectionView dequeueReusableCellWithReuseIdentifier:BoardCollectionViewCellID forIndexPath:indexPath];
if ([cell isKindOfClass:[QTBoardsCollectionViewCell class]]) {
QTBoardsCollectionViewCell *cellBoard = (QTBoardsCollectionViewCell *) cell;
[cellBoard setupModel];
// if (self.dataSourceBoards.count > 0) {
// cellBoard.boardModel = self.dataSourceBoards[indexPath.row];
// }
}
} else if (collectionView.collectionViewCellType == CellTypeUsers) {
cell = [collectionView dequeueReusableCellWithReuseIdentifier:UserCollectionViewCellID forIndexPath:indexPath];
if ([cell isKindOfClass:[QTUsersCollectionViewCell class]]) {
QTUsersCollectionViewCell *cellUser = (QTUsersCollectionViewCell *) cell;
[cellUser setupModel];
// if (self.dataSourceUsers.count > 0) {
// cellUser.userModel = self.dataSourceUsers[indexPath.row];
// }
}
}
return cell;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (![scrollView isKindOfClass:[UICollectionView class]])
return;

CGFloat horizontalOffset = scrollView.contentOffset.x;
QTCollectionView *collectionView = (QTCollectionView *) scrollView;
NSInteger index = collectionView.indexPath.row;
// 根据 collectionViewCellType 存储对应的偏移量
if (collectionView.collectionViewCellType == CellTypeExplores) {
self.contentOffsetDictionaryOfExplores[[@(index) stringValue]] = @(horizontalOffset);
} else if (collectionView.collectionViewCellType == CellTypeBoards) {
self.contentOffsetDictionaryOfBoards[[@(index) stringValue]] = @(horizontalOffset);
} else if (collectionView.collectionViewCellType == CellTypeUsers) {
self.contentOffsetDictionaryOfUsers[[@(index) stringValue]] = @(horizontalOffset);
}
}

由于 Cell 的重用机制,我们需要通过辅助手段,记住每个 UICollectionView 中滑动的偏移量,这里通过:- (void)scrollViewDidScroll:(UIScrollView *)scrollView 实现。

整个代码比较简单,辅助上注释,相信并不难理解。大功告成,我们来看下最后的实现效果吧:


感谢

本文绝大部分思想来自 Putting a UICollectionView in a UITableViewCell,再次表示感谢!

如果你是个 Swifter,这里提供一篇基于 Swift 的文章 https://www.thorntech.com/2015/08/want-your-swift-app-to-scroll-in-two-directions-like-netflix-heres-how/ 以供参考。


参考

  1. Putting a UICollectionView in a UITableViewCell
我知道是不会有人点的,但万一有人想不开呢?