iOS UICollectionViewFlowLayout 与瀑布流

    技术2022-07-10  90

    UICollectionView

    当我们使用代码行对 UICollectionView 进行初始化时,都不忘在前面创建一个 UICollectionViewFlowLayout 对象。因为我们可以通过UICollectionViewFlowLayout 来设定符合我们需求的 UICollectionView 布局。接下来,就让我们先来谈谈 UICollectionViewFlowLayout 的使用。

     

    一、UICollectionViewFlowLayout 的使用

    首先初始化一个  UICollectionViewFlowLayout 对象:

    UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];

    对 UICollectionViewCell 的相关布局约束:

    // 最小行间距,默认是0 layout.minimumLineSpacing = 5; // 最小左右间距,默认是10 layout.minimumInteritemSpacing = 5; // 区域内间距,默认是 UIEdgeInsetsMake(0, 0, 0, 0) layout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);

    在 UICollectionViewCell 上添加 UILabel 以便测试:

    - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseIdentifier forIndexPath:indexPath]; // cell 序号 UILabel *label = (UILabel *)[cell viewWithTag:10]; if (label == nil) { [cell setBackgroundColor:[UIColor cyanColor]]; label = [[UILabel alloc]init]; label.textAlignment = NSTextAlignmentCenter; label.font = [UIFont systemFontOfSize:80]; label.adjustsFontSizeToFitWidth = YES; label.tag = 10; [cell addSubview:label]; } [label setFrame:CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height)]; label.text = [[NSString alloc] initWithFormat:@"%ld", indexPath.row + 1]; return cell; }

    其中行间距和左右间距只能设置最小值。 所谓的最小值,就是系统在设置布局时,首先按 cell 的宽和最小左右间距横着排版下去一行最多可以排多少个,如果还有剩余空间的话,系统会将剩下的宽度平均分配该行的每一个左右间距,所以实际效果的左右间距会比设置的左右间距大的多。而行间距也是类似,在高度不一样的一行 cell 中,只有最高的 cell 是最小行间距,其余高度小的 cell 还会分配到与该行最高 cell 的高度差值“补贴”,具体情况后面有演示。

    在 8 plus 模拟器上,cell 的最小左右间距设置为 5,cell 设置大小为 120 x 120 时,

    layout.itemSize = CGSizeMake(120, 120);

    效果如下:

    cell 大小 120x120

    cell 设置大小为 100 x 100 时,

    layout.itemSize = CGSizeMake(100, 100);

    效果如下:

    cell 大小 100x100

    cell 设置大小为 80 x 80 时,

    layout.itemSize = CGSizeMake(80, 80);

    效果如下:

    cell 大小 80x80

    从模拟器的展示结果来看,排版的剩余宽度越大,左右间距也随之分配到更多的空间。比较过程中,我们也可以看到区域内间距  sectionInset 始终都是不变的,但是左右间距会因排版剩余宽度过多而变大,导致与不变的区域内间距差距就更大了。 有时候为了美观,我们需要将内间距和左右间距设置成一样的大小。这时,我们需要像系统一样先预算出在 cell 大小固定和左右间距取最小值的情况下,在一行中可以排版多少个 cell 。只不过,在分配剩余的宽度空间时,我们不仅将其分配给左右间距,还有区域内间距。

    以cell 大小 80x80 为例:

    UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; layout.itemSize = CGSizeMake(80, 80); // 以最小间距为10计算间距 // 每行可放多少 cell NSInteger nCountCell = (kScreenWidth - 10) / (layout.itemSize.width + 10); // 平均后的间距 CGFloat fSpacing = (kScreenWidth - layout.itemSize.width * nCountCell) / (nCountCell + 1); layout.minimumInteritemSpacing = fSpacing; layout.minimumLineSpacing = fSpacing; layout.sectionInset = UIEdgeInsetsMake(fSpacing, fSpacing, fSpacing, fSpacing);

    执行结果:

                        等间距

    以上讲的 UICollectionViewFlowLayout 使用是基于 cell 的大小是统一的情况。当有不同大小的 cell 放在同一个 UICollectionView 里,还要符合我们的心意排版,这个时候就需要自定义 UICollectionViewFlowLayout 类了,而专门适用于这种参差不齐 cell 的布局有个专业名词--“瀑布流”。

    以下来自百度百科关于瀑布流的介绍:

    瀑布流,又称瀑布流式布局。是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。最早采用此布局的网站是Pinterest,逐渐在国内流行开来。国内大多数清新站基本为这类风格。

    二、UICollectionViewFlowLayout 实现瀑布流

    有句谚语是这样讲的:

    巧妇难为无米之炊

    要想实现瀑布流布局,就需要先实现大小参差不齐的 cell,而要实现大小参差不齐的 cell ,就需要用到 UICollectionViewDelegate 的扩展协议 UICollectionViewDelegateFlowLayout,这个协议是在 UICollectionViewDelegate 的基础上增加了 UICollectionViewFlowLayout 一些可以根据 NSIndexPath 和 section来定制独立的布局属性,该协议提供多个可选方法。

    @protocol UICollectionViewDelegateFlowLayout <UICollectionViewDelegate> @optional - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath; - (UIEdgeInsets)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout insetForSectionAtIndex:(NSInteger)section; - (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section; - (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumInteritemSpacingForSectionAtIndex:(NSInteger)section; - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForHeaderInSection:(NSInteger)section; - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout referenceSizeForFooterInSection:(NSInteger)section; @end

    而我们要实现大小参差不齐的 cell ,就需要用到这个方法:

    - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath;

    我们使用随机数 arc4random() 将 cell 的高度设置在40到80之间:

    #pragma mark - UICollectionViewDelegateFlowLayout // cell 大小 - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { // 随机高度 return CGSizeMake(80, 40 + arc4random() % 40); }

    执行结果:

    高度在40~80之间的 cell

    从执行结果图中,我们可以看出系统在布局参差不齐的 cell 时 ,将会以每行高度最高的 cell 为基准,其余矮的 cell 的中心和这个高 cell 的中心保持在同一条水平线上。因此我们实现瀑布流,就需要先打破这个规则(每个  cell 依赖于它所在行的最高 cell 来布局它的位置),让每一个 cell 往已经排好且最短的列排版。 而要让每一个 cell 像人一样能思考地进行排版,我们需要重载 UICollectionViewFlowLayout 对所有 cell 属性的布局方法:

    -(NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect;

    因为 UICollectionView 的滚动内容大小是由 cell 的大小和数量决定的,系统又会以每一行最高 cell 的高度定为该行的高,然后逐个排版下去,导致重载布局方法之后,滚动到底部会出现一大堆空白。

    布局之后剩余的空白区

    虽然 - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath;方法可以为我们定制不同 cell的大小,但是 UICollectionView 滚动内容大小是根据系统的排版标准去算的,会导致滚动空间的溢出,所以这个方法在这种有上下缩进的情况下不适用。 因此为了准确地计算出 UICollectionView 合理的滚动内容大小,首先,我们需要在每一次更新布局前就引入各个 cell 的大小到自定义的 UICollectionViewFlowLayout 中,然后在准备方法 - (void)prepareLayout; 中,规划具体布局,接着,计算出瀑布流布局所需要滚动内容的高,以及在这么大的滚动内容大小下,每一个 cell 的 itemsize 要设置多大的平均值。

    具体代码实现。 自定义瀑布流头文件 MyFlowLayout.h :

    #import <UIKit/UIKit.h> /** 自定义瀑布流布局 */ @interface MyFlowLayout : UICollectionViewFlowLayout /** 瀑布流布局方法 @param itemWidth item 的宽度 @param itemHeightArray item 的高度数组 */ - (void)flowLayoutWithItemWidth:(CGFloat)itemWidth itemHeightArray:(NSArray<NSNumber *> *)itemHeightArray; @end

    实现文件 MyFlowLayout.m :

    #import "MyFlowLayout.h" @interface MyFlowLayout () /** item 的高度数组 */ @property (nonatomic, copy) NSArray<NSNumber *> *arrItemHeight; /** cell 布局属性集 */ @property (nonatomic, strong) NSArray<UICollectionViewLayoutAttributes *> *arrAttributes; @end @implementation MyFlowLayout /** 瀑布流布局方法 @param itemWidth item 的宽度 @param itemHeightArray item 的高度数组 */ - (void)flowLayoutWithItemWidth:(CGFloat)itemWidth itemHeightArray:(NSArray<NSNumber *> *)itemHeightArray { self.itemSize = CGSizeMake(itemWidth, 0); self.arrItemHeight = itemHeightArray; [self.collectionView reloadData]; } - (void)prepareLayout { [super prepareLayout]; // item 数量为零不做处理 if ([self.arrItemHeight count] == 0) { return; } // 计算一行可以放多少个项 NSInteger nItemInRow = (self.collectionViewContentSize.width - self.sectionInset.left - self.sectionInset.right + self.minimumInteritemSpacing) / (self.itemSize.width + self.minimumInteritemSpacing); // 对列的长度进行累计 NSMutableArray *arrmColumnLength = [NSMutableArray arrayWithCapacity:100]; for (NSInteger i = 0; i < nItemInRow; i++) { [arrmColumnLength addObject:@0]; } NSMutableArray *arrmTemp = [NSMutableArray arrayWithCapacity:100]; // 遍历设置每一个item的布局 for (NSInteger i = 0; i < [self.arrItemHeight count]; i++) { // 设置每个item的位置等相关属性 NSIndexPath *index = [NSIndexPath indexPathForItem:i inSection:0]; // 创建每一个布局属性类,通过indexPath来创建 UICollectionViewLayoutAttributes *attris = [self layoutAttributesForItemAtIndexPath:index]; CGRect recFrame = attris.frame; // 有数组得到的高度 recFrame.size.height = [self.arrItemHeight[i] doubleValue]; // 最短列序号 NSInteger nNumShort = 0; // 最短的长度 CGFloat fShortLength = [arrmColumnLength[0] doubleValue]; // 比较是否存在更短的列 for (int i = 1; i < [arrmColumnLength count]; i++) { CGFloat fLength = [arrmColumnLength[i] doubleValue]; if (fLength < fShortLength) { nNumShort = i; fShortLength = fLength; } } // 插入到最短的列中 recFrame.origin.x = self.sectionInset.left + (self.itemSize.width + self.minimumInteritemSpacing) * nNumShort; recFrame.origin.y = fShortLength + self.minimumLineSpacing; // 更新列的累计长度 arrmColumnLength[nNumShort] = [NSNumber numberWithDouble:CGRectGetMaxY(recFrame)]; // 更新布局 attris.frame = recFrame; [arrmTemp addObject:attris]; } self.arrAttributes = arrmTemp; // 因为使用了瀑布流布局使得滚动范围是根据 item 的大小和个数决定的,所以以最长的列为基准,将高度平均到每一个 cell 中 // 最长列序号 NSInteger nNumLong = 0; // 最长的长度 CGFloat fLongLength = [arrmColumnLength[0] doubleValue]; // 比较是否存在更短的列 for (int i = 1; i < [arrmColumnLength count]; i++) { CGFloat fLength = [arrmColumnLength[i] doubleValue]; if (fLength > fLongLength) { nNumLong = i; fLongLength = fLength; } } // 在大小一样的情况下,有多少行 NSInteger nRows = ([self.arrItemHeight count] + nItemInRow - 1) / nItemInRow; self.itemSize = CGSizeMake(self.itemSize.width, (fLongLength + self.minimumLineSpacing) / nRows - self.minimumLineSpacing); } // 返回所有的 cell 布局数组 -(NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect { return self.arrAttributes; } @end

    在视图控制器上的调用:

    MyFlowLayout *layout = [[MyFlowLayout alloc] init]; // 创建随机高度的数组 NSMutableArray *arrmHeight = [NSMutableArray arrayWithCapacity:100]; for (NSInteger i = 0; i < 50; i++) { // 40~80 的随机高度 [arrmHeight addObject:[NSNumber numberWithDouble:40 + arc4random() % 40]]; } [layout flowLayoutWithItemWidth:80 itemHeightArray:arrmHeight]; // 以最小间距为10计算间距 // 每行可放多少 cell NSInteger nCountCell = (kScreenWidth - 10) / (layout.itemSize.width + 10); // 平均后的间距 CGFloat fSpacing = (kScreenWidth - layout.itemSize.width * nCountCell) / (nCountCell + 1); layout.minimumInteritemSpacing = fSpacing; layout.minimumLineSpacing = fSpacing; layout.sectionInset = UIEdgeInsetsMake(fSpacing, fSpacing, fSpacing, fSpacing); self.collection = [[UICollectionView alloc] initWithFrame:CGRectMake(0, 64, kScreenWidth, kScreenHeight - 64) collectionViewLayout:layout]; _collection.backgroundColor = [UIColor whiteColor]; _collection.delegate = self; _collection.dataSource = self; [self.view addSubview:_collection]; // 注册 cell [_collection registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:cellId];

    执行结果:

    平均 cell 的 itemSize 之后

    除了这种高度参差不齐的 cell,我们在开发过程中,还会遇到宽度参差不齐的布局。

    三、UICollectionViewFlowLayout 的靠左布局

    因为在宽度参差不齐的布局中,不会影响滚动内容的高度,所以我们可以通过 - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath; 来实现 itemSize 的不规则:

    // cell 大小 - (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath { // 随机宽度 return CGSizeMake(40 + arc4random() % 4 * 20, 50); }

    UICollectionViewFlowLayout 的属性设置:

    //创建流水布局对象 UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init]; layout.minimumLineSpacing = 10; layout.minimumInteritemSpacing = 10; layout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);

    UICollectionViewDelegate 和 UICollectionViewDataSource 的相关实现:

    - (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section { return 70; } - (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:reuseIdentifier forIndexPath:indexPath]; // Configure the cell UILabel *label = (UILabel *)[cell viewWithTag:10]; if (label == nil) { // 添加子控件 label = [[UILabel alloc]init]; label.textAlignment = NSTextAlignmentCenter; label.font = [UIFont systemFontOfSize:30]; label.adjustsFontSizeToFitWidth = YES; label.tag = 10; [cell addSubview:label]; } [label setFrame:CGRectMake(0, 0, cell.frame.size.width, cell.frame.size.height)]; label.text = [[NSString alloc] initWithFormat:@"%ld", indexPath.row + 1]; return cell; }

    执行结果:

    宽度不规则的 cell

    从结果中可以看出,因为宽度不一样导致每一行排版剩余的空间不一样,导致左右间距也就相差较大了。 为了排版美观,我们有时候需要靠左布局。之前因为项目有过这样的产品需求,在网络上找到了一个第三方 UICollectionViewFlowLayout 自定义类--UICollectionViewLeftAlignedLayout。UICollectionViewLeftAlignedLayout 下载地址

    在视图控制器上的使用:

    UICollectionViewLeftAlignedLayout *layout = [[UICollectionViewLeftAlignedLayout alloc] init]; layout.minimumLineSpacing = 10; layout.minimumInteritemSpacing = 10; layout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);

    执行结果:

    靠左布局的 cell

     

     

    Processed: 0.011, SQL: 9