美文网首页iOS DeveloperiOS进阶之路GXiOS
iOS 横向滚动、水平布局、分组显示

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

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


      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

    相关文章

      网友评论

        本文标题:iOS 横向滚动、水平布局、分组显示

        本文链接:https://www.haomeiwen.com/subject/eyuwhqtx.html