ios开发中,UICollectionView是一个极其重要的组件,我们通过自定义UICollectionViewFlowLayout可以实现一些极其复杂的布局。
项目demo地址为:
demo地址
这次主要实现的布局主要有以下三种:
-不规则高度瀑布流两列排序
-瀑布流不规则高度多列排列
-横向滑动第一个cell放大
主要介绍这三种布局通过自定义UICollectionViewFlowLayout如何实现,demo的GitHub链接放在文末
瀑布流不规则高度两列布局
在demo中的实现的文件名为firstViewController.m和waterFlowLayout.m,定义一个array数组,用于保存不规则的高度
@property (nonatomic, strong) NSMutableArray *array;//用于存储高度数据
- (NSMutableArray *)array {
if (_array) {
return _array;
}
_array = [NSMutableArray arrayWithObjects:@(100),@(150),@(150),@(50),@(130),@(180),@(200),@(100),@(110),@(200), nil];
return _array;
}
总共10个高度数据,这个可以自己定义,宽度通过计算得到,使其刚好可以放下两列数据
代码如下
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
float width = self.view.bounds.size.width - 10 - 10 - 10;//10分别为左边剧、右边距以及minimumInteritemSpacing
float height = [[self.array objectAtIndex:indexPath.row] floatValue];
return CGSizeMake(width / 2, height);
}
我们先使用UICollectionViewFlowLayout看一下实现的效果图
这种布局里面有很多空白,看上去很不舒服,我们想的是将其填满,所以需要自定义UICollectionViewflowlayout实现,在其中我们需要使用到的自定义的方法的作用
//初始化自定义的flowLayout
- (void)prepareLayout {
//必须调用super
[super prepareLayout];
}
//滑动时会时时调用此方法 改变布局
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
}
//替换最终滑动的contentOffset, proposedContentOffset是预期滑动停止的位置
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
}
// 当collectionView bounds改变时,是否重新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES;
}
//返回collectionView的最终大小
- (CGSize)collectionViewContentSize {
}
自定义flowLayout参考链接
为了实现瀑布流排列,我们需要知道每一行排列后第一列和第二列的最大的y值,因为只有两列,所以我们可以通过奇偶判断来进行实现(下面多列时会使用其他的思路,也适合于两列的情况)
新建一个文件继承自UICollectionViewFlowLayout,命名为waterFlowLayout
在.h文件中定义一个继承自UICollectionViewDelegateFlowLayout的协议WaterFlowLayoutDelegate
并且
@property (nonatomic, weak) id<WaterFlowLayoutDelegate> delegate;
.h文件中定义一些属性
@property (nonatomic, strong) NSMutableArray<UICollectionViewLayoutAttributes *> *itemAttributes;//存储各个cell的属性
@property (nonatomic) CGFloat xOffset;
@property (nonatomic) CGFloat yOffset;
@property (nonatomic) CGFloat maxOddY;//奇数时最大的y
@property (nonatomic) CGFloat maxEvenY;//偶数时最大的y
在.m文件中
- (void)prepareLayout {
[super prepareLayout];
//初始化定义的属性
self.scrollDirection = UICollectionViewScrollDirectionVertical;//竖直方向滑动
self.itemAttributes = [NSMutableArray array];
self.xOffset = 0;
self.yOffset = 0;
self.maxOddY = 0;
self.maxEvenY = 0;
//获取UICollectionView的一些基本属性
//获取元素个数
NSInteger count = [self.collectionView numberOfItemsInSection:0];
//获取collectionView的组边距
UIEdgeInsets sectionEdgeInsets = [self.delegate collectionView:self.collectionView layout:self insetForSectionAtIndex:0];
//自定义属性赋初值
self.xOffset = sectionEdgeInsets.left;
self.yOffset = sectionEdgeInsets.top;
self.maxOddY = self.yOffset;
self.maxEvenY = self.yOffset;
//对于每个cell
for (int i = 0; i < count; i++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
//获取对应的cell的大小
CGSize itemSize = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:indexPath];
if (i % 2 == 0) {
//如果是偶数列,即左边这列,因为i从0开始的,初始的xoffset设置为左边距,初始的yOffset设置为距离顶部的边距
self.xOffset = sectionEdgeInsets.left;
self.yOffset = self.maxEvenY;
} else {
//如果为奇数列,即右边这列,xOffset为左边距+cell的宽度+cell之间的间距
self.xOffset = sectionEdgeInsets.left + itemSize.width + self.minimumInteritemSpacing;
self.yOffset = self.maxOddY;
}
//获取对应的indexPath的cell的属性
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
//给cell赋值新的frame
attributes.frame = CGRectMake(self.xOffset, self.yOffset, itemSize.width, itemSize.height);
[self.itemAttributes addObject:attributes];
if (i % 2 == 0) {
//偶数列即左边这列,maxEvenY最大的y偏移为初始的偏移maxEvenY + cell的高度 + cell竖向之间的距离
self.maxEvenY = self.maxEvenY + itemSize.height +
self.minimumLineSpacing;
} else {
//奇数列中,最大偏移同上
self.maxOddY = self.maxOddY + itemSize.height + self.minimumLineSpacing;
}
}
}
在上面中,两列分别使用maxEvenY 和maxOddY来记录两边的最大y偏移,并且对每个cell重新计算frame,存储到itemAttributes中,接下来只要返回这个itemAttributes即可。
- (nullable NSArray<__kindof UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
return self.itemAttributes;
}
这样之后,我们运行之后的效果图为
我们可以看到,在滑到底部时,collectionView会有很大的剩余,为了解决这个问题,我们需要重新限制collectionView的大小,在自定义flowLayout中
- (CGSize)collectionViewContentSize {
//maxOddY和maxEvenY正好是我们计算y方向的偏移量,我们取其最大值
float height = MAX(self.maxOddY, self.maxEvenY);
return CGSizeMake(self.collectionView.bounds.size.width, height);
}
之后运行,得到的就是我们下面的效果图
不规则的宽度和高度的排列
上面讲了两列的排列,通过奇偶来实现的,有很大的局限性,下面讲的是:宽度固定,但是不是两列,是多列的情况下,高度不固定的实现,文末会付上相应的github地址,其中的实现文件为secondViewController.m和waterFlowLayoutNew.m
我们首先在secondViewController中定义数组,分别存放自定义高度
@property (nonatomic, strong) NSMutableArray *heightArray;//用于存储高度数据
- (NSMutableArray *)heightArray {
if (_heightArray) {
return _heightArray;
}
_heightArray = [NSMutableArray arrayWithObjects:@(100),@(150),@(150),@(50),@(130),@(180),@(200),@(100),@(110),@(200), nil];
return _heightArray;
}
对于每个collectionViewCell的大小设置为:
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
NSInteger count = 3;//每行排列的cell的个数
float width = self.view.bounds.size.width - 10 * 2 - 10 * (count - 1);
float height = [[self.heightArray objectAtIndex:indexPath.row] floatValue];
return CGSizeMake(width / count, height);
}
通过count设置自定义的列数(这里以3列为例子,后面只需修改该数值即可)
初始时,使用UICollectionViewFlowLayout,此时的布局效果如下:
这个布局很显然不满足我们的要求,通过自定义flowLayout实现相应的效果
新建一个继承自UICollectionViewLayout的类,命名为waterFlowLayoutNew,在该类中,定义一个协议继承自UICollectionViewDelegateFlowLayout,代码如下:
@protocol WaterFlowLayoutNewDelegate <UICollectionViewDelegateFlowLayout>
@end
@property (nonatomic, weak) id<WaterFlowLayoutNewDelegate> delegate;
然后定义相应的属性
//每个cell的属性数组
@property (nonatomic, strong) NSMutableArray<UICollectionViewLayoutAttributes *> *itemAttributes;
@property (nonatomic) CGFloat xOffset;
@property (nonatomic) CGFloat yOffset;
//每一行的个cell的个数
@property (nonatomic) NSInteger perLineCount;
在.m文件中,
- (void)prepareLayout {
[super prepareLayout];
//初始化定义的属性
self.scrollDirection = UICollectionViewScrollDirectionVertical;
self.itemAttributes = [NSMutableArray array];
self.xOffset = 0;
self.yOffset = 0;
//获取相应的collectionView的属性
NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
UIEdgeInsets sectionInsets = [self.delegate collectionView:self.collectionView layout:self insetForSectionAtIndex:0];
self.xOffset = sectionInsets.left;
self.yOffset = sectionInsets.top;
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
CGSize size = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:indexPath];
//因为cell的宽度是固定的(我们自定义的宽度都是一样的,现在就是处理的是宽度固定、高度不固定的多列排列),所以我们可以求出每行的cell个数
NSInteger count = floor (self.collectionView.bounds.size.width - sectionInsets.left - sectionInsets.right + self.minimumInteritemSpacing) / (size.width + self.minimumInteritemSpacing);
self.perLineCount = count;
//定义一个数组,用于存储每行每一个cell的yOffset,因为每个cell的高度不同,所以下一行的cell布局需要用到它
NSMutableArray *yOffsetArray = [NSMutableArray arrayWithCapacity:self.perLineCount];
for (int i = 0; i < itemCount;i++) {
//获取每一个cell的大小
CGSize itemSize = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
//当这一行还可以放下cell时
if (self.xOffset + sectionInsets.right + itemSize.width <= self.collectionView.bounds.size.width) {
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
//当布局的不是第一行的时候
if (yOffsetArray.count == self.perLineCount) {
//yOffset为同一列上一行的yOffset偏移量,存储在数组中 + 行与行之间的间距
self.yOffset = [[yOffsetArray objectAtIndex:(i % self.perLineCount)] floatValue] + self.minimumLineSpacing;
}
attributes.frame = CGRectMake(self.xOffset, self.yOffset, itemSize.width, itemSize.height);
//存储cell的属性
[self.itemAttributes addObject:attributes];
//下一个cell的xOffset
self.xOffset = self.xOffset + itemSize.width + self.minimumInteritemSpacing;
if (yOffsetArray.count < self.perLineCount) {
//布局第一行时yOffset为空,没法使用replace,所以使用addObject
[yOffsetArray addObject:@(self.yOffset + itemSize.height)];
} else {
//布局第二行及往后,数组不为空,直接替换相应的数组对象
[yOffsetArray replaceObjectAtIndex:(i % self.perLineCount) withObject:@(self.yOffset + itemSize.height)];
}
} else {
//当这一行放不下时,需要换行放置
self.xOffset = sectionInsets.left;
//从数组中获取上一行同一列的yoffset + 行与行之间的间距
self.yOffset = [[yOffsetArray objectAtIndex:(i % self.perLineCount)] floatValue] + self.minimumLineSpacing;
UICollectionViewLayoutAttributes *attributes = [UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:[NSIndexPath indexPathForRow:i inSection:0]];
attributes.frame = CGRectMake(self.xOffset, self.yOffset, itemSize.width, itemSize.height);
//存储cell的属性
[self.itemAttributes addObject:attributes];
//下一个cell的xOffset
self.xOffset = self.xOffset + itemSize.width + self.minimumInteritemSpacing;
self.yOffset = self.yOffset + itemSize.height;
//更新yOffsetArray数组
[yOffsetArray replaceObjectAtIndex:(i % self.perLineCount) withObject:@(self.yOffset)];
}
}
}
上面先算出每一行的cell的个数,然后创建一个同样大小的数组,每放置一个cell,就将cell的最大偏移量yOffset存储或者更新到yOffsetArray数组中,然后将得到的每个cell的attributes存储到数组中itemAttributes中
//返回每个cell的属性
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
return self.itemAttributes;
}
//更新collectionView的大小
- (CGSize)collectionViewContentSize {
return CGSizeMake(self.collectionView.bounds.size.width, self.yOffset);
}
最终得到的效果图为(3列的情况下)
修改count为4,即是4列等宽度的情况
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
NSInteger count = 4;//每行排列的cell的个数
float width = self.view.bounds.size.width - 10 * 2 - 10 * (count - 1);
float height = [[self.heightArray objectAtIndex:indexPath.row] floatValue];
return CGSizeMake(width / count, height);
}
相应的效果图为:
这种方法的适用性更广,比2列情况下的奇偶解决思路要好一些。
横向滑动时第一个cell放大的实现
要实现的最终效果如下:
先使用UICollectionViewFlowLayout来实现一个简单的滑动,滑动方向设置为水平滑动。
设置cell的大小
- (CGSize)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout *)collectionViewLayout sizeForItemAtIndexPath:(NSIndexPath *)indexPath {
return CGSizeMake(100, 100);
}
最终得到的效果图为:
滑动过程中cell大小不会变化,下面开始使用自定义的flowLayout
新建一个文件继承自UICollectionViewFlowLayout,命名为FirstCellZoomInLayoutTwo
在.h文件中,定义相应的协议,协议中定义一个协议方法,用于实现对第一个cell的frame放大
@protocol FirstCellZoomInLayoutTwoDelegate <UICollectionViewDelegateFlowLayout>
- (CGSize)sizeForFirstCell;
@end
.h文件中定义相应的协议
@property (nonatomic, weak) id<FirstCellZoomInLayoutTwoDelegate> delegate;
在.m文件中
- (void)prepareLayout {
[super prepareLayout];
//必须要设置,因为默认的滑动方向是竖直滑动
self.scrollDirection = UICollectionViewScrollDirectionHorizontal;//设置滑动方向为水平滑动
}
在ThirdViewController.m中,遵守协议,实现相应的协议方法
- (CGSize)sizeForFirstCell {
return CGSizeMake(120, 150);
}
接着需要修改第一个cell的frame
在FirstCellZoomInLayoutTwo.m文件中
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
//获取原有的所有cell的布局信息
NSArray *originalArr = [super layoutAttributesForElementsInRect:rect];
//获取collectionView的相关属性
UIEdgeInsets sectionInsets = [self.delegate collectionView:self.collectionView layout:self insetForSectionAtIndex:0];
CGSize itemSize = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
//因为加上了self.collectionView.contentoffset.x,所以first代表的是显示在界面上的最左边的cell
CGRect first = CGRectMake(sectionInsets.left + self.collectionView.contentOffset.x, sectionInsets.top + self.collectionView.contentOffset.y, itemSize.width, itemSize.height);
//需要放大的cell的大小
CGSize firstCellSize = [self.delegate sizeForFirstCell];
CGFloat totalOffset = 0;
for (UICollectionViewLayoutAttributes *attributes in originalArr) {
CGRect originFrame = attributes.frame;
//判断两个矩形是否相交,判断cell与需要放大的cell的frame是否有相交,如果相交,需要修改cell的frame
if (CGRectIntersectsRect(first, originFrame)) {
//如果相交,获取两个矩形相交的区域
CGRect insertRect = CGRectIntersection(first, originFrame);
attributes.size = CGSizeMake(itemSize.width + (insertRect.size.width / itemSize.width) * (firstCellSize.width - itemSize.width), itemSize.height + (insertRect.size.width) / (itemSize.width) * (firstCellSize.height - itemSize.height));
//计算因为放大第一个导致的cell的中心点的偏移量
CGFloat currentOffsetX = attributes.size.width - itemSize.width;
attributes.center = CGPointMake(attributes.center.x + totalOffset + currentOffsetX / 2, attributes.center.y);
totalOffset = totalOffset + currentOffsetX;
} else {
//当cell的最小x值大于第一个cell的最大x值时,cell的偏移量需要加上totolOffset
if (CGRectGetMinX(originFrame) >= CGRectGetMaxX(first)) {
attributes.center = CGPointMake(attributes.center.x + totalOffset, attributes.center.y);
}
}
}
return originalArr;
}
上面处理的思路是:
判断各个cell与第一个cell是否相交(只有第一个cell才能与其相交),相交的话将该cell的宽高设为放大后的宽高,中心点的偏移量为currentOffsetX的一半,如果不相交的话,中心点的偏移量为currentOffset,不用除以二
这样之后的运行效果为:
可以看到此时的frame大小滑动的过程中会出现各种异常,这是因为collectionView的bounds发生变化,我们没有及时更新导致的,需要加上以下方法
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES;
}
这是运行的效果图为:
从上面的图上可以看出,已经基本满足要求,但是滑动后停留的位置不准,接下来需要对这个进行处理
在- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity方法中进行处理
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
//获取collectionView的相关属性
UIEdgeInsets sectionInsets = [self.delegate collectionView:self.collectionView layout:self insetForSectionAtIndex:0];
CGSize itemSize = [self.delegate collectionView:self.collectionView layout:self sizeForItemAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]];
CGFloat finalPointX = 0;
//根据最终的偏移量计算出最终停留的cell的index值,向上取整
NSInteger index = ceil(proposedContentOffset.x / (itemSize.width + self.minimumInteritemSpacing));
//根据index值计算出相应的偏移量
finalPointX = (itemSize.width + self.minimumInteritemSpacing) * index;
//得到最终停留点
CGPoint finalPoint = CGPointMake(finalPointX, proposedContentOffset.y);
return finalPoint;
}
最终的效果图为:
总结
通过自定义UICollectionViewFlowLayout可以实现一些复杂的布局效果,
demo地址