欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

iOS UICollectionViewFlowLayout 与瀑布流

程序员文章站 2022-07-02 16:06:39
...

iOS UICollectionViewFlowLayout 与瀑布流

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);

效果如下:

iOS UICollectionViewFlowLayout 与瀑布流

cell 大小 120x120

cell 设置大小为 100 x 100 时,

layout.itemSize = CGSizeMake(100, 100);

效果如下:

iOS UICollectionViewFlowLayout 与瀑布流

cell 大小 100x100

cell 设置大小为 80 x 80 时,

layout.itemSize = CGSizeMake(80, 80);

效果如下:

iOS UICollectionViewFlowLayout 与瀑布流

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);

执行结果:

iOS UICollectionViewFlowLayout 与瀑布流
                    等间距

以上讲的 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 的高度设置在4080之间:

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

执行结果:

iOS UICollectionViewFlowLayout 与瀑布流

高度在40~80之间的 cell

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

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

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

布局之后剩余的空白区

iOS UICollectionViewFlowLayout 与瀑布流

虽然 - (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];

执行结果:

iOS UICollectionViewFlowLayout 与瀑布流

平均 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;
}

执行结果:

iOS UICollectionViewFlowLayout 与瀑布流

宽度不规则的 cell

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

在视图控制器上的使用:

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

执行结果:

靠左布局的 cell

iOS UICollectionViewFlowLayout 与瀑布流