iOS 横向滚动、水平布局、分组显示

前言
  • 首先,我们通过标题可知,本篇文章的核心思想就是如何优雅的实现横向滚动、水平布局、分组显示功能,具体业务细节还请先看下方👇效果图;其次,效果图这种功能,我们平时使用场景很多,比如:表情键盘聊天框中的更多面板直播软件中的礼物面板等等,当然实现的方式有很多种,这里笔者将介绍几种主流的优雅实现方案,希望能与大家产生共鸣;最后,希望该篇文章能为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。
  • 效果图


    Example.gif
  • 源码地址:MHDevelopExample/Classes/Horizontal
分析
  • UI设计图

    Analysis.png

  • 需求分析

    1. 横向滚动: 绿色框 CollectionView需要支持横向滚动,实现起来无非是设置UICollectionViewFlowLayoutscrollDirectionUICollectionViewScrollDirectionHorizontal即可。
    2. 水平布局: 绿色框 CollectionView中的内容(PS:粉红色框最近-0...等)需要支持水平布局,即从左到右,从上到下
    3. 分组显示: 分组可认为最近特色心情、...等都分别为独立的一组,一个组里面含有多个元素。例如:最近-0这个只是最近这个组里面的一个元素罢了。
    4. 分页处理: 考虑到每组含有多个元素,每组分页处理按照:每页9个元素,每组页数从1n(n>1)。例如:假设最近这组含有11个元素,则可以分为2页,即[0,1]特色这组含有24个元素,则可以分为3页,即 [0 , 3] ...
  • UI控件

    1. 通过上面的UI图可知,本次功能实现中我们所用到的主要控件如下:红色框 UIScrollView黄色框 UIPageControl绿色框 UICollectionView。可能会有部分人会认为绿色框 也使用UIScrollView控件来实现,这里笔者只能说这虽然是可以实现,但是实现起来并不是非常优雅,有悖于笔者的写该文章的初心。
    2. 为什么绿色框使用UIScrollView控件实现本文章的功能就不够优雅?答案是:没有复用 。所以,平常我们为了解决视图复用问题,最常用的套路不就是:UITableView + UITableViewCellUICollectionView + UICollectionViewCell 这两种吗。再考虑到横向滚动,UICollectionView不就正满足条件嘛。
    3. 控件的正确选择,是优雅的实现横向滚动、水平布局、分组显示功能的首要条件,当然,针对绿色框 ,也就是UICollectionView + UICollectionViewCell中的UICollectionViewCell的内容布局样式的不同,决定了实现横向滚动、水平布局、分组显示功能的难易度、优雅度以及具体实现细节处理,从而衍生出来本文的几种方案实现,话不多说,且听笔者一一道来。
方案一

针对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拆分为多个以9PageSize来分页,每组能分出多少页,则代表该组能分出多少个小数组,从而表明这组需要多少个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 : ⚠️,这些警告是笔者开发中要提醒大家要特别注意的地方,还请大家多多留意。

/// 方法一:<常用>
[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页... 则数据源返回的Section2+3=5

空白补齐(EmptyCell):按页分段则必须保证每个section返回的item个数必须是9个,对应于collectionView的数据源方法- collectionView: numberOfItemsInSection:必须返回9。但是,我们知道,每组的元素个数,按照9个来分页,最后一页可能不足9个,这样如果按照collectionView的流水布局,显示出来的效果与理想情况相差甚远,如下图👇所示:所以最终结论必须要保证每个section返回的item个数必须是9个,但是为了避免最后一页数组越界以及界面展示问题,这里引入emptyCell,即一个背景颜色为clearColorUICollectionViewCell,所以图中理想情况下红色块即为emptyCell。由此可知,我们只需要在collectionView的返回Cell的数据源方法- collectionView: cellForItemAtIndexPath:,根据是否超过最后一页的数据个数返回不同的Cell即可。

空白补齐.png

索引转换:空白补齐虽然解决了流水布局而引起的每段section不能分页的问题,但是我们可以清楚的看到cell的排版顺序又与我们理想的情况相差甚远,原因是系统的流水布局,在横向滚动的情况下,并不是按照水平布局(从左到右,从上到下)的,而是按照垂直布局(从上到下,从左到右)的,如下图所示。

索引转换.png

为了达到理想情况,这里就需要我们做索引转换。这里笔者提供了两种实现索引转换的方案,关键代码如下:

/// 方式一
/// 根据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流水布局来实现方案二,就避免不了数据分段空白补齐索引转换等问题,实现起来并不是非常优雅,优雅的实现方案应该如下:

  1. 共有多少组(group),则返回多少段(section)。
  2. 每组多少个元素,则每段返回多少个item
  3. 无需索引转换,无需引入其他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;
}

  • 友情提醒

关于计算CellitemSize,而引起布局紊乱的问题。例如:上边距: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的三个函数:ceilfloorroundmath.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的数据源方法处理逻辑就比较简单了,主要逻辑以及代码实现如下:

  1. 总共多少组,则返回多少段(section) ;
  2. 每段只返回1个item
  3. 返回内部嵌套collectionViewcell
// 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,首先嵌套的collectionViewCell,是不是完全可以采用方案一方案二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,嵌套的CollectionViewCell即可以选择方案一(1个Cell== 9个黑块),也可以选择**方案二**(`1个`Cell` == 1个`黑块)的形式;
期待
  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:MHDevelopExample/Classes/Horizontal
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容