前言
- 首先,我们通过标题可知,本篇文章的核心思想就是如何优雅的实现
横向滚动、水平布局、分组显示
功能,具体业务细节还请先看下方👇效果图;其次,效果图这种功能,我们平时使用场景很多,比如:表情键盘
,聊天框中的更多面板
,直播软件中的礼物面板
等等,当然实现的方式有很多种,这里笔者将介绍几种主流的优雅实现方案,希望能与大家产生共鸣;最后,希望该篇文章能为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。 -
效果图
- 源码地址:MHDevelopExample/Classes/Horizontal
分析
-
UI设计图
-
需求分析
-
横向滚动:
绿色框 CollectionView
需要支持横向滚动,实现起来无非是设置UICollectionViewFlowLayout
的scrollDirection
为UICollectionViewScrollDirectionHorizontal
即可。 -
水平布局:
绿色框 CollectionView
中的内容(PS:粉红色框最近-0
...等)需要支持水平布局,即从左到右,从上到下
。 -
分组显示: 分组可认为
最近
、特色
、心情
、...等都分别为独立的一组,一个组里面含有多个元素。例如:最近-0
这个只是最近
这个组里面的一个元素罢了。 -
分页处理: 考虑到每组含有多个元素,每组分页处理按照:每页
9
个元素,每组页数从1
至n(n>1)
。例如:假设最近
这组含有11
个元素,则可以分为2
页,即[0,1]
;特色
这组含有24
个元素,则可以分为3
页,即[0 , 3]
...
-
横向滚动:
-
UI控件
- 通过上面的UI图可知,本次功能实现中我们所用到的主要控件如下:
红色框 UIScrollView
、黄色框 UIPageControl
、绿色框 UICollectionView
。可能会有部分人会认为绿色框
也使用UIScrollView
控件来实现,这里笔者只能说这虽然是可以实现,但是实现起来并不是非常优雅,有悖于笔者的写该文章的初心。 - 为什么
绿色框
使用UIScrollView
控件实现本文章的功能就不够优雅?答案是:没有复用
。所以,平常我们为了解决视图复用问题
,最常用的套路不就是:UITableView + UITableViewCell
和UICollectionView + UICollectionViewCell
这两种吗。再考虑到横向滚动,UICollectionView
不就正满足条件嘛。 - 控件的正确选择,是优雅的实现
横向滚动、水平布局、分组显示
功能的首要条件,当然,针对绿色框
,也就是UICollectionView + UICollectionViewCell
中的UICollectionViewCell
的内容布局样式的不同,决定了实现横向滚动、水平布局、分组显示
功能的难易度、优雅度以及具体实现细节处理,从而衍生出来本文的几种方案实现,话不多说,且听笔者一一道来。
- 通过上面的UI图可知,本次功能实现中我们所用到的主要控件如下:
方案一
针对UICollectionViewCell
(后面统称 Cell )的内容组成,方案一采用是:一个Cell
由 九个黑块
组成。也就是一个Cell
的大小跟绿色框 collectionView
大小一致,确定了Cell
内部的子控件组成,剩下就是具体的业务逻辑实现了。本文笔者主要针对的是业务逻辑的实现,一些UI布局、控件创建、UI逻辑,就不搬到文章上来了,届时大家可以通过笔者提供的Demo
,查看即可。
- 数据处理
页面的展示,离不开数据的支持,本期所使用到的数据模型:组模型 MHHorizontalGroup
和元素模型 MHHorizontal
,具体内容去下:
@interface MHHorizontalGroup : NSObject
/// idstr
@property (nonatomic, readwrite, copy) NSString *idstr;
/// Name
@property (nonatomic, readwrite, copy) NSString *name;
/// 数据列表
@property (nonatomic, readwrite, copy) NSArray<MHHorizontal *> *horizontals;
@end
@interface MHHorizontal : NSObject
/// idstr
@property (nonatomic, readwrite, copy) NSString *idstr;
/// Name
@property (nonatomic, readwrite, copy) NSString *name;
/// 是否选中
@property(nonatomic, readwrite, assign, getter=isSelected) BOOL selected;
@end
数据处理- _configureData
关键代码实现:
/// 配置数据
- (void)_configureData{
/// 原始的组数据
self.horizontalGroups = [MHHorizontalGroup fetchHorizontalGroups];
/// 记录第一项
self.tempHorizontal = [[self.horizontalGroups.firstObject horizontals] firstObject];
/// 总数
NSInteger groupCount = self.horizontalGroups.count;
/// 索引
NSInteger pageIndex = 0;
/// 索引数组
NSMutableArray *pageIndexs = [NSMutableArray array];
/// 页数组
NSMutableArray *pageCounts = [NSMutableArray array];
/// mappingTable
NSMutableDictionary *mappingTable = [NSMutableDictionary dictionary];
/// 配置分页数据
for (NSInteger g = 0 ; g < groupCount ; g++) {
MHHorizontalGroup *group = self.horizontalGroups[g];
NSMutableArray *temps = [NSMutableArray array];
/// 计算分页总数公式: pageCount = (totalRecords + pageSize - 1) / pageSize //取得所有页数
NSInteger count = group.horizontals.count;
NSInteger pageCount = (count + MHHorizontalPageSize - 1)/MHHorizontalPageSize;
/// 计算数据
for (NSInteger page = 0; page < pageCount; page++) {
/// 计算range
NSInteger loc = page * MHHorizontalPageSize;
NSInteger len = (page < (pageCount-1))?MHHorizontalPageSize:(count%MHHorizontalPageSize);
/// 取出数据
NSArray *arr = [group.horizontals subarrayWithRange:NSMakeRange(loc, len)];
/// 添加数组
[temps addObject:arr];
/// 加入page映射表
[mappingTable setObject:@(g) forKey:@(page+pageIndex)];
}
/// 添加索引
[pageIndexs addObject:@(pageIndex)];
/// 添加页数
[pageCounts addObject:@(pageCount)];
/// 每组索引增加
pageIndex += pageCount;
/// 总页数
self.horizontalGroupTotalPageCount += pageCount;
/// 加入数据源
[self.dataSource addObject:temps.copy];
}
/// 赋值
self.horizontalGroupPageIndexs = [pageIndexs copy];
self.horizontalGroupPageCounts = [pageCounts copy];
self.pageMappingTable = [mappingTable copy];
/// 刷新数据
[self.contentView reloadData];
}
方案一的数据处理,主要核心是将每组的元素列表horizontals
拆分为多个以9
为PageSize
来分页,每组能分出多少页
,则代表该组能分出多少个小数组
,从而表明这组需要多少个Cell
。例如:假设最近
这组的horizontals
装着11
个元素,则可以拆分出2
页,则拆分的小数组也为2
个,分别为:[最近-0 .... 最近-8]
和 [最近-9 ... 最近-10]
。
这里笔者分享一个分页计算公式:pageCount = (totalRecords + pageSize - 1) / pageSize
。
当然,我们把分组显示
和 分页处理
的业务也考虑进来,其处理逻辑也会变得更加复杂。例如:最近
这组含有11
个元素,则分成2
页,页索引为 [0 1]
;特色
这组含有24
个元素,则分成3
页,页索引 [0 2]
,总页数一共5
页,也就是当我们横向滚动时,通过collectionView.contentOffset.x/collectionView.frame.size.width
计算的出来的索引(page
)则为 [0 4]
,所以横向滚动时的分页逻辑的伪代码如下:
NSInteger page = collectionView.contentOffset.x/collectionView.frame.size.width;
if(page < 2){
/// 0. 分组显示:`最近`
// 1. pageControl内容
self.pageControl.currentPage = page - 0;
self.pageControl.numberOfPages = 2;
}else if(page < 5){
/// 0. 分组显示:`特色`
/// 1. pageControl内容
self.pageControl.currentPage = page - 2;
self.pageControl.numberOfPages = 3;
}
...
如果我们在UIScrollView
代理方法- scrollViewDidScroll:
中处理上面伪代码的逻辑,一系列的if - else
是不是会引起你的极度不适。这里笔者通过👇一个Excel
表,来解释- _configureData
方法内部具体的业务逻辑,建议大家先参看Excel
表,再去看代码实现,就会明白笔者的良苦用心了,主要关键词:数据分页
,组起始页
,每组总页数
、所有组总页数
、组映射表
,组索引
等。
- scrollViewDidScroll:
中的代码实现如下:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (_isScroll == NO) { return; }
// 1、定义获取需要的数据
CGFloat progress = 0;
NSInteger originalIndex = 0;
NSInteger targetIndex = 0;
// 2、判断是左滑还是右滑
CGFloat currentOffsetX = scrollView.contentOffset.x;
CGFloat scrollViewW = scrollView.bounds.size.width;
if (currentOffsetX > _startOffsetX) { // 左滑
// 1、计算 progress
progress = currentOffsetX / scrollViewW - floor(currentOffsetX / scrollViewW);
// 2、计算 originalIndex
originalIndex = currentOffsetX / scrollViewW;
// 3、计算 targetIndex
targetIndex = originalIndex + 1;
if (targetIndex >= self.horizontalGroupTotalPageCount) {
progress = 1;
targetIndex = self.horizontalGroupTotalPageCount - 1;
}
// 4、如果完全划过去
if (currentOffsetX - _startOffsetX == scrollViewW) {
progress = 1;
targetIndex = originalIndex;
}
} else { // 右滑
// 1、计算 progress
progress = 1 - (currentOffsetX / scrollViewW - floor(currentOffsetX / scrollViewW));
// 2、计算 targetIndex
targetIndex = currentOffsetX / scrollViewW;
// 3、计算 originalIndex
originalIndex = targetIndex + 1;
if (originalIndex >= self.horizontalGroupTotalPageCount) {
originalIndex = self.horizontalGroupTotalPageCount - 1;
}
}
/// 通过page映射表,获取组索引
NSInteger originalGroupIndex = [[self.pageMappingTable objectForKey:@(originalIndex)] integerValue];
NSInteger targetGroupIndex = [[self.pageMappingTable objectForKey:@(targetIndex)] integerValue];
/// 处理PageTitle
[self _setPageTitleViewWithProgress:progress originalIndex:originalGroupIndex targetIndex:targetGroupIndex];
/// 处理pageControl
if (progress >= 0.8) {;
NSInteger pageIndex = [self.horizontalGroupPageIndexs[targetGroupIndex] integerValue];
self.pageControl.currentPage = targetIndex - pageIndex;
self.pageControl.numberOfPages = [self.horizontalGroupPageCounts[targetGroupIndex] integerValue];;
}
}
- UIPageControl联动
前面的所说的需求,是针对全局只有一个UIPageControl
的情况,假设目前要做成UIPageControl联动
的效果,即最近
这组对应一个UIPageControl
,特色
这组对应一个UIPageControl
,.... ,总之,有多少组,就有多少个UIPageControl
,且需要跟随collectionView
横向滚动,当切换到另一组时,UIPageControl
也跟着切换,从而达到联动
的效果。
其实现方案很简单,将黄色框
控件用UIScrollView
控件代替即可,内部创建多个UIPageControl
即可。然后在UIScrollView
代理方法- scrollViewDidScroll:
中处理pageControl
的联动即可,详情请参照- scrollViewDidScroll:
中的代码实现,其关键代码如下:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (_isScroll == NO) { return; }
// 1、定义获取需要的数据
CGFloat progress = 0;
NSInteger originalIndex = 0;
NSInteger targetIndex = 0;
// 2、判断是左滑还是右滑
CGFloat currentOffsetX = scrollView.contentOffset.x;
CGFloat scrollViewW = scrollView.bounds.size.width;
if (currentOffsetX > _startOffsetX) { // 左滑
// 1、计算 progress
progress = currentOffsetX / scrollViewW - floor(currentOffsetX / scrollViewW);
// 2、计算 originalIndex
originalIndex = currentOffsetX / scrollViewW;
// 3、计算 targetIndex
targetIndex = originalIndex + 1;
if (targetIndex >= self.horizontalGroupTotalPageCount) {
progress = 1;
targetIndex = self.horizontalGroupTotalPageCount - 1;
}
// 4、如果完全划过去
if (currentOffsetX - _startOffsetX == scrollViewW) {
progress = 1;
targetIndex = originalIndex;
}
} else { // 右滑
// 1、计算 progress
progress = 1 - (currentOffsetX / scrollViewW - floor(currentOffsetX / scrollViewW));
// 2、计算 targetIndex
targetIndex = currentOffsetX / scrollViewW;
// 3、计算 originalIndex
originalIndex = targetIndex + 1;
if (originalIndex >= self.horizontalGroupTotalPageCount) {
originalIndex = self.horizontalGroupTotalPageCount - 1;
}
}
/// 通过page映射表,获取组索引
NSInteger originalGroupIndex = [[self.pageMappingTable objectForKey:@(originalIndex)] integerValue];
NSInteger targetGroupIndex = [[self.pageMappingTable objectForKey:@(targetIndex)] integerValue];
/// 处理PageTitle
[self _setPageTitleViewWithProgress:progress originalIndex:originalGroupIndex targetIndex:targetGroupIndex];
/// 处理pageControl 滚动
[self _setPageControlScrollViewWithProgress:progress originalIndex:originalGroupIndex targetIndex:targetGroupIndex];
/// 处理pageControl
if (progress >= 0.8) {
///起始索引
NSInteger pageIndex = [self.horizontalGroupPageIndexs[targetGroupIndex] integerValue];
/// 取出pageControl
UIPageControl *pageControl = self.pageControls[targetGroupIndex];
pageControl.currentPage = targetIndex - pageIndex;
pageControl.numberOfPages = [self.horizontalGroups[targetGroupIndex] numberOfPages];
}
}
/// pageControlScrollView联动
- (void)_setPageControlScrollViewWithProgress:(CGFloat)progress originalIndex:(NSInteger)originalIndex targetIndex:(NSInteger)targetIndex{
/// 设置
CGFloat offsetX = originalIndex * self.pageControlScrollView.mh_width + (targetIndex - originalIndex) * self.pageControlScrollView.mh_width *progress;
/// 滚动
[self.pageControlScrollView setContentOffset:CGPointMake(offsetX, 0) animated:NO];
}
这里面有个小注意的地方,就是直接将UIPageControl
添加到UIScrolllView
上,会导致显示UIPageControl
紊乱,原因暂且不明,解决方案:创建一个tempView
,把UIPageControl
添加到tempView
上,然后再把tempView
添加到UIScrollView
上即可。
笔者友情提醒:控制器里面搜索#warning CMH : ⚠️
,这些警告是笔者开发中要提醒大家要特别注意的地方,还请大家多多留意。
-
CollectionView
刷新闪烁问题。解决方案
/// 方法一:<常用>
[UIView performWithoutAnimation:^{
//刷新界面
[self.collectionView reloadData];
/// - reloadItemsAtIndexPaths:
/// - reloadSections:
}];
/// 方法二
[UIView animateWithDuration:0 animations:^{
[collectionView performBatchUpdates:^{
[collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index inSection:0]]];
} completion:nil];
}];
/// 方法三
[UIView setAnimationsEnabled:NO];
[self.trackPanel performBatchUpdates:^{
[collectionView reloadItemsAtIndexPaths:@[[NSIndexPath indexPathForItem:index inSection:0]]];
} completion:^(BOOL finished) {
[UIView setAnimationsEnabled:YES];
}];
/// 隐式动画,上面方案解决不了
如果你的APP只支持iOS7+,推荐使用第一种方式performWithoutAnimation简单方便。
上面说的方法只能解决UIView的Animation,但是如果你的cell中还包含有CALayer的动画,比如这样:
- (void)layoutSubviews{
[super layoutSubviews];
self.frameLayer.frame = self.frameView.bounds; /// 存在隐式动画
}
上述情况多用于自定义控件使用了layer.mask的情况,如果有这种情况,上面提到的方法是无法取消CALayer的动画的,但是解决办法也很简单:
- (void)layoutSubviews{
[super layoutSubviews];
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.frameLayer.frame = self.frameView.bounds;
[CATransaction commit];
}
方案二
方案二采用的是:一个Cell
由一个黑块
组成。也就是说,最近-0
是一个Cell
,最近-1
也是一个Cell
...,这种方案完全有别于方案一
,且该方案的实现也比较注重细节,知识点比较多,当然大家所能学到的知识(套路)也会更多。
- 数据处理
数据分段(Section
):说到分段
,则对应于collectionView
的数据源方法- numberOfSectionsInCollectionView:
,相比方案一
而言,方案一
是一组就是一段,共有多少组,则数据源返回多少段即可。但是,对于方案二
来言,这种方案是完全行不通的,方案二
采取的是按页分段
,数据源方法- numberOfSectionsInCollectionView:
返回的是总页数
,例如:最近
这组共有11
个元素,则可分2
页;特色
这组共有24
个元素,则分3
页... 则数据源返回的Section
为2+3=5
。
空白补齐(EmptyCell
):按页分段
则必须保证每个section
返回的item
个数必须是9
个,对应于collectionView
的数据源方法- collectionView: numberOfItemsInSection:
必须返回9
。但是,我们知道,每组的元素个数,按照9
个来分页,最后一页可能不足9
个,这样如果按照collectionView
的流水布局,显示出来的效果与理想情况相差甚远,如下图👇所示:所以最终结论必须要保证每个section
返回的item
个数必须是9
个,但是为了避免最后一页数组越界以及界面展示问题,这里引入emptyCell
,即一个背景颜色为clearColor
的UICollectionViewCell
,所以图中理想情况下红色块
即为emptyCell
。由此可知,我们只需要在collectionView
的返回Cell
的数据源方法- collectionView: cellForItemAtIndexPath:
,根据是否超过最后一页的数据个数返回不同的Cell
即可。
索引转换:空白补齐
虽然解决了流水布局而引起的每段section
不能分页的问题,但是我们可以清楚的看到cell
的排版顺序又与我们理想的情况相差甚远,原因是系统的流水布局,在横向滚动的情况下,并不是按照水平布局(从左到右,从上到下)
的,而是按照垂直布局(从上到下,从左到右)
的,如下图所示。
为了达到理想情况,这里就需要我们做索引转换
。这里笔者提供了两种实现索引转换
的方案,关键代码如下:
/// 方式一
/// 根据UI索引返回数据索引
- (NSInteger)_way0_dataIndexFromUIIndex:(NSInteger)uiIndex {
/* 水平布局的collectionView显示cell的顺序是:
* (3x3) 0, 3, 6
* 1, 4, 7
* 2, 5, 8
*
* 实际一页需要显示的顺序是:
* (3x3) 0, 1, 2
* 3, 4, 5
* 6, 7, 8
*/
/// 利用公式,公式都是推导出来的,理解的不是非常深刻
NSUInteger ip = uiIndex / MHHorizontalPageSize;
NSUInteger ii = uiIndex % MHHorizontalPageSize;
NSUInteger reIndex = (ii % MHHorizontalMaxRow) * MHHorizontalMaxColumn + (ii / MHHorizontalMaxRow);
uiIndex = reIndex + ip * MHHorizontalPageSize;
return uiIndex;
}
/// 方式二
- (NSInteger)_way1_dataIndexFromUIIndex:(NSInteger)uiIndex {
/* 水平布局的collectionView显示cell的顺序是:
* (3x3) 0, 3, 6
* 1, 4, 7
* 2, 5, 8
*
* 实际一页需要显示的顺序是:
* (3x3) 0, 1, 2
* 3, 4, 5
* 6, 7, 8
*/
NSArray *map = @[@0, @3, @6, @1, @4, @7, @2, @5, @8];
/// 这种方式,通过 UI索引 映射出 data索引这种,直观度远远高于方式一,也比较好理解
return (uiIndex / map.count) * map.count + [map[uiIndex % map.count] integerValue];
}
- 重写布局
如果采用系统提供的UICollectionViewFlowLayout
流水布局来实现方案二
,就避免不了数据分段
、空白补齐
、索引转换
等问题,实现起来并不是非常优雅,优雅的实现方案应该如下:
- 共有多少组(
group
),则返回多少段(section
)。 - 每组多少个
元素
,则每段返回多少个item
。 - 无需
索引转换
,无需引入其他Cell
。
为了实现,这里需要用到自定义UICollectionViewLayout
来实现,其实UICollectionView
的强大之处,就是在于支持自定义布局
。实际上对于 UICollectionView
的自定义layout
,只需要时刻记住一个准则就不会出现问题:布局的更新一定是线性的,而不能跳跃。 如何自定义UICollectionViewLayout
这里就不过多阐述了,详情内容请查看MHCollectionViewHorizontalFlowLayout.h/m
的申明和实现,其关键代码如下:
/// CollectionView会在初次布局时首先调用该方法
/// CollectionView会在布局失效后、重新查询布局之前调用此方法
/// 子类中必须重写该方法并调用父类的方法
- (void)prepareLayout{
[super prepareLayout];
/// reset data
[self.attributesM removeAllObjects];
[self.sectionPageCounts removeAllObjects];
[self.sectionHomepageIndexs removeAllObjects];
self.totalPageCount = 0;
/// 0. 获取所有section
NSInteger numberOfSections = [self.collectionView numberOfSections];
if (numberOfSections == 0) { /// 容错
return;
}
/// 你若敢瞎传负数或0 我就Crash
NSAssert(self.columnCount > 0 , @"MHCollectionViewHorizontalFlowLayout columnCount should be greater than 0");
NSAssert(self.rowCount > 0 , @"MHCollectionViewHorizontalFlowLayout rowCount should be greater than 0");
/// pageSize
self.pageSize = self.rowCount * self.columnCount;
/// 1. 计算
/// 起始索引
NSInteger homepageIndex = 0;
for (NSInteger section = 0; section < numberOfSections; section++) {
/// 每段总item数
NSInteger numberOfItems = [self.collectionView numberOfItemsInSection:section];
/// 记录索引
[self.sectionHomepageIndexs addObject:@(homepageIndex)];
/// 计算分页总数公式: pageCount = (totalrecords + pageSize - 1) / pageSize
/// 取得所有该section的总页数
NSInteger pageCount = (numberOfItems + self.pageSize - 1)/self.pageSize;
/// 记录每段总页数
[self.sectionPageCounts addObject:@(pageCount)];
/// 计算总页数
self.totalPageCount += pageCount;
/// 索引自增 pageCount
homepageIndex += pageCount;
/// 计算所有 item的布局属性
for (NSInteger idx = 0; idx < numberOfItems; idx++){
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:idx inSection:section];
UICollectionViewLayoutAttributes *arr = [self layoutAttributesForItemAtIndexPath:indexPath];
[self.attributesM addObject:arr];
}
}
}
/// 子类必须重写此方法。
/// 并使用它来返回CollectionView视图内容的宽高,
/// 这个值代表的是所有的内容的宽高,并不是当前可见的部分。
/// CollectionView将会使用该值配置内容的大小来促进滚动。
- (CGSize)collectionViewContentSize{
CGFloat width = self.totalPageCount * self.collectionView.bounds.size.width;
return CGSizeMake(width, self.collectionView.bounds.size.height);
}
/// 返回UICollectionViewLayoutAttributes 类型的数组,
/// UICollectionViewLayoutAttributes 对象包含cell或view的布局信息。
/// 子类必须重载该方法,并返回该区域内所有元素的布局信息,包括cell,追加视图和装饰视图。
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
return self.attributesM;
}
/// 返回指定indexPath的item的布局信息。子类必须重载该方法,该方法
/// 只能为cell提供布局信息,不能为补充视图和装饰视图提供。
- (nullable UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath{
/*
* 1. Get section-specific metrics (minimumInteritemSpacing,minimumLineSpacing, sectionInset)
*/
CGFloat minimumInteritemSpacing = [self _evaluatedMinimumInteritemSpacingForSectionAtIndex:indexPath.section];
CGFloat minimumLineSpacing = [self _evaluatedMinimumLineSpacingForSectionAtIndex:indexPath.section];
UIEdgeInsets sectionInset = [self _evaluatedSectionInsetForItemAtIndex:indexPath.section];
/// collectionView 宽和高
CGFloat width = self.collectionView.bounds.size.width;
CGFloat height = self.collectionView.bounds.size.height;
/// 内容显示的 宽和高
CGFloat contentW = width - sectionInset.left - sectionInset.right;
CGFloat contentH = height - sectionInset.top - sectionInset.bottom;
/// 这里假设每个item是等宽和等高的
CGFloat itemW = MHFloorCGFloat((contentW - (self.columnCount - 1) * minimumInteritemSpacing)/self.columnCount);
CGFloat itemH = MHFloorCGFloat((contentH - (self.rowCount - 1) * minimumLineSpacing)/self.rowCount);
/// 当前Section的当前页
NSInteger currentPage = indexPath.item / self.pageSize;
/// 当前section的起始页X
CGFloat sectionHomepageX = [self.sectionHomepageIndexs[indexPath.section] integerValue] * width;
/// 计算 item 的 X 和 Y
CGFloat itemX = sectionInset.left + (itemW + minimumInteritemSpacing) * (indexPath.item % self.columnCount) + currentPage * width;
itemX = sectionHomepageX + itemX;
CGFloat itemY = sectionInset.top + (itemH + minimumLineSpacing) * ((indexPath.item - self.pageSize * currentPage) / self.rowCount);
/// 获取原布局
UICollectionViewLayoutAttributes* attributes = [[super layoutAttributesForItemAtIndexPath:indexPath] copy];
/// 更新布局
attributes.frame = CGRectMake(itemX, itemY, itemW, itemH);
return attributes;
}
- 友情提醒
关于计算Cell
的itemSize
,而引起布局紊乱的问题。例如:上边距:10,左边距:10,下边距:10,右边距:10,中间间距:10,显示3行3列,这时候通常我们设置UICollectionViewFlowLayout
的属性如下:
flowLayout.minimumLineSpacing = 10;
flowLayout.minimumInteritemSpacing = 10;
CGFloat itemW = (collectionView.frame.size.width - 2 * 10 - 2*10)/3 ;
CGFloat itemH = (collectionView.frame.size.height - 2 * 10 - 2 * 10)/3;
flowLayout.sectionInset = UIEdgeInsetsMake(10,10,10,10);
flowLayout.itemSize = CGSizeMake(itemW, itemH);
上面代码是不是非常完美,但是显示出来的效果,不同屏幕却有可能不是理想情况下的九宫格样式(3x3),why?答案就是,计算出来的itemW 和 itemH 不是一个确定的小数,可能是无穷小数。例如:itemW = 124.66666666666667,itemH = 174.33333333333334
。但是,我们设置 flowLayout.sectionInset
又是确定的数值UIEdgeInsetsMake(10,10,10,10)
,然而itemW 、itemH
由于精度问题,取值可能为itemW = 124.7 、itemH = 174.3
,这样难免会导致(itemW * 3 + 左边距 + 右边距 + 2 * 中间间距) > collectionView.frame.size.width
从而导致布局紊乱。 解决方案如下:
CGFloat YYScreenScale() {
static CGFloat scale;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
scale = [UIScreen mainScreen].scale;
});
return scale;
}
/// floor point value for pixel-aligned
static inline CGFloat CGFloatPixelFloor(CGFloat value) {
CGFloat scale = YYScreenScale();
return floor(value * scale) / scale;
}
/// round point value for pixel-aligned
static inline CGFloat CGFloatPixelRound(CGFloat value) {
CGFloat scale = YYScreenScale();
return round(value * scale) / scale;
}
/// ceil point value for pixel-aligned
static inline CGFloat CGFloatPixelCeil(CGFloat value) {
CGFloat scale = YYScreenScale();
return ceil(value * scale) / scale;
}
CGFloat collectionViewW = collectionView.frame.size.width;
CGFloat collectionViewH = collectionView.frame.size.height;
/// 上边距:10,左边距:10,下边距:10,右边距:10,中间间距:10,显示3行3列
CGFloat itemW = CGFloatPixelFloor((collectionViewW - 2 * 10 - 2*10)/3) ;
CGFloat itemH = CGFloatPixelFloor((collectionViewH - 2 * 10 - 2 * 10)/3);
/// 计算左右边距
CGFloat insetLeft = (collectionViewW - 3 * itemW - 2 * 10)/2.0f;
CGFloat insetRight = (collectionViewW - 3 * itemW - 2 * 10 - insetLeft);
/// 计算上下边距
CGFloat insetTop = (collectionViewH - 3 * itemH - 2 * 10)/2.0f;
CGFloat insetBottom = (collectionViewH - 3 * itemH - 2 * 10 - insetTop);
/// 设置flowLayout
flowLayout.minimumLineSpacing = 10;
flowLayout.minimumInteritemSpacing = 10;
flowLayout.sectionInset = UIEdgeInsetsMake(insetLeft , insetRight , insetTop , insetBottom);
flowLayout.itemSize = CGSizeMake(itemW, itemH);
这里笔者分享math.h
的三个函数:ceil
、 floor
、round
,math.h
定义如下:
extern float ceilf(float);
extern double ceil(double);
extern long double ceill(long double);
extern float floorf(float);
extern double floor(double);
extern long double floorl(long double);
extern float roundf(float);
extern double round(double);
extern long double roundl(long double);
各个函数的作用如下:
round 如果参数是小数 则求本身的四舍五入. <四舍五入>
ceil 如果参数是小数 则求最小的整数但不小于本身. <向上取整>
floor 如果参数是小数 则求最大的整数但不大于本身. <向下取整>
事例代码如下:
round(3.4) --- 3 ceil(3.4) --- 4 floor(3.4) --- 3
round(3.5) --- 4 ceil(3.5) --- 4 floor(3.5) --- 3
CGFloat tempNum = 5.234;
tempNum *= 100;
NSLog(@"%.2f", ceil(tempNum)/100); // 打印的为5.24
方案三
方案三:采用的是 Cell内部嵌套一个UICollectionView
,也就是说:绿色框 collectionView
中的Cell (PS:跟绿色框大小一致)
的内部添加了一个UICollectionView
的子控件。 该方案,最外面绿色框 collectionView
的数据源方法处理逻辑就比较简单了,主要逻辑以及代码实现如下:
- 总共多少组,则返回多少段(
section
) ; - 每段只返回1个
item
; - 返回内部嵌套
collectionView
的cell
。
// 0 : 9个黑块 == 1个Cell 👉 MHHorizontalMode0Cell
// 1 : 1个黑块 == 1个Cell 👉 MHHorizontalMode1Cell + UICollectionViewFlowLayout
// 2 : 1个黑块 == 1个Cell 👉 MHHorizontalMode1Cell + MHCollectionViewHorizontalFlowLayout
#define MHHorizontalMode2CellDebug 2
#pragma mark - - - UICollectionViewDataSource & UICollectionViewDelegate
- (NSInteger)numberOfSectionsInCollectionView:(UICollectionView *)collectionView{
return self.horizontalGroups.count;
}
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
#warning CMH : ⚠️ 每个Cell嵌套一个UICollectionView,故为1
return 1;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
#if MHHorizontalMode2CellDebug == 0
MHHorizontalMode2Cell0 *cell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass(MHHorizontalMode2Cell0.class) forIndexPath:indexPath];
cell.group = self.horizontalGroups[indexPath.section];
cell.delegate = self;
#elif MHHorizontalMode2CellDebug == 1
MHHorizontalMode2Cell1 *cell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass(MHHorizontalMode2Cell1.class) forIndexPath:indexPath];
cell.group = self.horizontalGroups[indexPath.section];
cell.delegate = self;
#else
MHHorizontalMode2Cell2 *cell = [collectionView dequeueReusableCellWithReuseIdentifier:NSStringFromClass(MHHorizontalMode2Cell2.class) forIndexPath:indexPath];
cell.group = self.horizontalGroups[indexPath.section];
cell.delegate = self;
#endif
return cell;
}
先讲讲Cell内部嵌套的collectionView
,首先嵌套的collectionView
的Cell
,是不是完全可以采用方案一
或方案二
的Cell
来实现,是不是有点绕?仔细想想是不是很有道理?可以先看看MHHorizontalMode2CellDebug
这个宏。所以这里笔者还是着重讲讲该方案,需要特别注意的地方以及填坑的过程。
Cell
赋值则导致嵌套的collectionView
显示问题
Bug复现:最近
这组11
个元素,可分成2
页;特色
这组24
个元素,可分成3
页。假设我们想从最近
这组滚动到特色
这组,若横向滚动绿色框
,首先会响应Cell
内部嵌套的collectionView
,从而达到横向滚动,当滚动到这组最后一页,在接续滚动,则会响应绿色框的collectionView
滚动,从而将最近
这组滚动到特色
这组,滚动过程伪代码如下:
/// 组内滚动,响应Cell嵌套的collectionView
// `最近`
// 0 - 开始滚动
pageControl.currentPage = 0; /// 0
pageControl.numberOfPages = 2;
/// 1 - 滚动一页
pageControl.currentPage = 1; /// 1 代表cell嵌套的`collectionView`已经滚动到尽头,再继续向右滚动,会响应外部的`collectionView`滚动
pageControl.numberOfPages = 2;
/// 2 - 继续滚动 则切换到 特色
/// 组与组之间滚动,响应的是外部的`collectionView`的滚动,当我们滚动到`特色`这组,又开始响应`cell`内部嵌套的`collectionViewCell`滚动
/// 特色
pageControl.currentPage = 0; /// 0
pageControl.numberOfPages = 3;
/// 3 - 继续滚动
pageControl.currentPage = 1; /// 1
pageControl.numberOfPages = 3;
/// 4 - 继续滚动
pageControl.currentPage = 2; /// 2 代表cell嵌套的`collectionView`已经滚动到尽头,再继续向右滚动,会响应外部的`collectionView`滚动
pageControl.numberOfPages = 3;
...
/// 注意:我们从`最近` 滚动到 `特色` ,不会出现问题,但是我们从`特色` 滚动到 `最近`就会出现问题,这里用伪代码表示一下:
/// 理想情况
// `特色`
// 0 - 开始滚动
pageControl.currentPage = 0; /// 0 代表cell嵌套的`collectionView`位于该组第一页,再继续向左滚动,会响应外部的`collectionView`滚动
pageControl.numberOfPages = 3;
// 1 - 继续滚动 则切换到 最近
/// `最近`
pageControl.currentPage = 1; /// 1
pageControl.numberOfPages = 2;
// 2 - 继续滚动
pageControl.currentPage = 0; /// 0
pageControl.numberOfPages = 2;
/// 现实情况
// `特色`
// 0 - 开始滚动
pageControl.currentPage = 0; /// 0 代表cell嵌套的`collectionView`位于该组第一页,再继续向左滚动,会响应外部的`collectionView`滚动
pageControl.numberOfPages = 3;
// 1 - 继续滚动 则切换到 最近
/// `最近`
pageControl.currentPage = 0; /// 0
pageControl.numberOfPages = 2;
Bug原因:当我们从一组切换到另一组时,我们需要给Cell
传递一个group
模型,然后重写其setter
方法,进行数据配置,分页处理,刷新Cell
内部嵌套的collectionView
,主要原因就是:刷新Cell
内部嵌套的collectionView
,从而导致本该显示第二页数据,一刷新就跑到第一页数据的Bug。
Bug解决:解决方案很简单,当响应cell
嵌套的collectionView
滚动时,需要记录每一组当前滚动到哪一页currentPage
,以及这一组的总页数numberOfPages
,当我们需要给Cell
传递一个group
模型,重写其setter
方法,配置好数据源,刷新Cell
内部嵌套的collectionView
,最后需要:将cell
嵌套collectionView
的滚动到当前组之前记录的页currentPage
即可。实现代码如下:
#pragma mark - Setter
- (void)setGroup:(MHHorizontalGroup *)group{
_group = group;
NSInteger count = group.horizontals.count;
NSMutableArray *temps = [NSMutableArray array];
/// 计算分页总数公式: pageCount = (totalrecords + pageSize - 1) / pageSize //取得所有页数
NSInteger pageCount = (count + MHHorizontalPageSize - 1)/MHHorizontalPageSize;
/// 计算数据
for (NSInteger page = 0; page < pageCount; page++) {
/// 计算range
NSInteger loc = page * MHHorizontalPageSize;
NSInteger len = (page < (pageCount-1))?MHHorizontalPageSize:(count%MHHorizontalPageSize);
/// 取出数据
NSArray *arr = [group.horizontals subarrayWithRange:NSMakeRange(loc, len)];
/// 添加数组
[temps addObject:arr];
}
self.dataSource = temps.copy;
[self.collectionView reloadData];
#warning CMH : ⚠️ 这里必须将collectionView滚到currentPage
CGFloat offsetX = group.currentPage * self.mh_width;
[self.collectionView setContentOffset:CGPointMake(offsetX, 0) animated:NO];
}
-
UIPageControl联动
- 首先参照
方案一
的联动
实现方案,这里就不在赘述。 - 但是对于
方案三
实现UIPageControl联动
还有种优雅的方法:Cell 身上有两个子控件,一个是UICollectionView
控件,另一个是UIPageControl
控件。这种方案比方案一
实现起来更加简单,更能体现封装的好处,但关于这种联动
方案的实现,大家完全可以自己查看笔者提供的代码即可,这里笔者就不做过多的阐述了,毕竟思路才是重点。
- 首先参照
总结
以上内容就是笔者在现实开发中优雅
的实现横向滚动、水平布局、分组显示
等功能需求的3
种方案,三种方案各自都有不同的闪光点
,且综合性能都非常好,希望大家在现实开发中,能够按照自己在理解各个方案的核心点或注意点的基础上,按需选择即可。可能各个方案在实现细节上,各有千秋,这里笔者就不做过多的阐述了,大家只需要跑跑Demo
,看看源码,就一定会理解的。最后,我们用数字的形式总结一下三个方案吧:
-
方案一: 1个
Cell
== 9个黑块
; -
方案二: 1个
Cell
== 1个黑块
; -
方案三:
Cell
嵌套CollectionView
,嵌套的CollectionView
的Cell
即可以选择方案一(1个
Cell== 9个
黑块),也可以选择**方案二**(`1个`Cell` == 1个`黑块
)的形式;
期待
- 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
- 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
- GitHub地址:https://github.com/CoderMikeHe
- 源码地址:MHDevelopExample/Classes/Horizontal