iOS开发基础67-UICollectionView 自定义流水布局深度解析:横向滚动中间放大效果
UICollectionView 自定义流水布局深度解析:横向滚动中间放大效果
本文系统梳理 UICollectionView 自定义布局:通过继承 UICollectionViewFlowLayout 重写核心方法,实现横向滚动卡片中间放大、两侧缩小、停止滚动自动吸附到中心的效果。重点修复多行问题:通过内边距控制 + 重写 layoutAttributesForItemAtIndexPath: 强制所有 cell 垂直居中,确保 collectionView 只有一行。
一、问题分析:为什么确保 collectionView 只有一行?
在横向滚动的 UICollectionViewFlowLayout 中:
minimumLineSpacing是列与列之间的间距(横向)minimumInteritemSpacing是同一列中 cell 之间的间距(纵向)
当 collectionView 的高度大于 cell 高度时,FlowLayout 会在垂直方向尝试放下多个 cell(同一列堆叠多个),导致出现多行,不是我们要的效果。
确保只有一行的两个关键手段:
- 内边距控制:
sectionInset.top/bottom= (collectionView 高度 - cell 高度) / 2,让垂直方向刚好容纳一个 cell。 - 强制居中(双重保险):重写
layoutAttributesForItemAtIndexPath:,强制每个 cell 的 y 坐标居中。
二、自定义布局的核心方法
1. prepareLayout(布局初始化)
- (void)prepareLayout {
[super prepareLayout];
// 横向滚动
self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
// cell 大小(如果外部未设置,用默认值)
if (CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
self.itemSize = CGSizeMake(200, 300);
}
// 列间距(横向,cell 之间的水平间距)
self.minimumLineSpacing = 20;
// 同一列内 cell 间距(纵向,设为 0 避免意外换行)
self.minimumInteritemSpacing = 0;
// 关键:上下内边距 = (collectionView 高度 - cell 高度) / 2
// 让垂直方向刚好只能放一个 cell,确保只有一行
CGFloat verticalInset = (self.collectionView.frame.size.height - self.itemSize.height) * 0.5;
// 左右内边距 = (collectionView 宽度 - cell 宽度) / 2,让首尾 cell 也能居中
CGFloat horizontalInset = (self.collectionView.frame.size.width - self.itemSize.width) * 0.5;
self.sectionInset = UIEdgeInsetsMake(verticalInset, horizontalInset, verticalInset, horizontalInset);
}
核心:
verticalInset让上下内边距刚好填满 cell 之外的垂直空间,FlowLayout 计算时垂直方向只能放下一个 cell,不会换行。
2. layoutAttributesForItemAtIndexPath:(强制每个 cell 居中,双重保险)
/**
* 重写此方法,强制每个 cell 在垂直方向居中
* 这是确保只有一行的双重保险,即使内边距计算有偏差也不会换行
*/
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewLayoutAttributes *attrs = [super layoutAttributesForItemAtIndexPath:indexPath];
// 强制 cell 垂直居中:y = (collectionView 高度 - cell 高度) / 2
CGRect frame = attrs.frame;
frame.origin.y = (self.collectionView.frame.size.height - frame.size.height) * 0.5;
attrs.frame = frame;
return attrs;
}
3. layoutAttributesForElementsInRect:(缩放微调)
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
// 注意:这里不能直接用 super 返回的数组(super 返回的属性 y 可能不居中)
// 需要遍历 rect 范围内的 indexPath,用重写后的 layoutAttributesForItemAtIndexPath: 获取
NSMutableArray *result = [NSMutableArray array];
// 获取 super 计算的属性,用于确定哪些 cell 在 rect 范围内
NSArray *superArray = [super layoutAttributesForElementsInRect:rect];
for (UICollectionViewLayoutAttributes *attrs in superArray) {
if (attrs.representedElementCategory == UICollectionElementCategoryCell) {
// 用重写后的方法获取居中属性
UICollectionViewLayoutAttributes *newAttrs = [self layoutAttributesForItemAtIndexPath:attrs.indexPath];
[result addObject:newAttrs];
} else {
// supplementary view(header/footer)直接用 super 的
[result addObject:attrs];
}
}
// 当前可见区域中心点 x
CGFloat centerX = self.collectionView.contentOffset.x + self.collectionView.frame.size.width * 0.5;
// 遍历缩放(只对 cell)
for (UICollectionViewLayoutAttributes *attrs in result) {
if (attrs.representedElementCategory != UICollectionElementCategoryCell) {
continue;
}
CGFloat delta = ABS(attrs.center.x - centerX);
// 缩放公式:scale = 1 - delta / width * scaleFactor
CGFloat scale = 1 - delta / self.collectionView.frame.size.width * self.scaleFactor;
scale = MAX(scale, self.minScale); // 限制最小缩放比例
attrs.transform = CGAffineTransformMakeScale(scale, scale);
}
return result;
}
注意:这里不能直接返回
[super layoutAttributesForElementsInRect:]的结果,因为 super 返回的 cell 属性 y 坐标可能不居中。需要通过重写的layoutAttributesForItemAtIndexPath:获取居中后的属性。
4. shouldInvalidateLayoutForBoundsChange:(滚动时重新布局)
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES; // 滚动时实时更新缩放
}
5. targetContentOffsetForProposedContentOffset:withScrollingVelocity:(吸附对齐)
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGRect rect;
rect.origin.y = 0;
rect.origin.x = proposedContentOffset.x;
rect.size = self.collectionView.frame.size;
// 用重写后的方法获取居中属性
NSArray *array = [self layoutAttributesForElementsInRect:rect];
CGFloat centerX = proposedContentOffset.x + self.collectionView.frame.size.width * 0.5;
CGFloat minDelta = MAXFLOAT;
for (UICollectionViewLayoutAttributes *attrs in array) {
if (attrs.representedElementCategory != UICollectionElementCategoryCell) {
continue;
}
if (ABS(minDelta) > ABS(attrs.center.x - centerX)) {
minDelta = attrs.center.x - centerX;
}
}
proposedContentOffset.x += minDelta;
return proposedContentOffset;
}
三、完整实现
HWCoverFlowLayout.h
#import <UIKit/UIKit.h>
@interface HWCoverFlowLayout : UICollectionViewFlowLayout
/// 缩放因子(默认 0.4,值越大两侧 cell 缩得越小)
@property (nonatomic, assign) CGFloat scaleFactor;
/// 最小缩放比例(默认 0.6,防止 cell 缩得太小)
@property (nonatomic, assign) CGFloat minScale;
@end
HWCoverFlowLayout.m
#import "HWCoverFlowLayout.h"
@implementation HWCoverFlowLayout
- (instancetype)init {
if (self = [super init]) {
_scaleFactor = 0.4;
_minScale = 0.6;
}
return self;
}
#pragma mark - 1. 布局初始化
- (void)prepareLayout {
[super prepareLayout];
// 横向滚动
self.scrollDirection = UICollectionViewScrollDirectionHorizontal;
// 默认 cell 大小
if (CGSizeEqualToSize(self.itemSize, CGSizeZero)) {
self.itemSize = CGSizeMake(200, 300);
}
// 列间距(横向)
self.minimumLineSpacing = 20;
// 同一列内 cell 纵向间距(设为 0,配合内边距确保单行)
self.minimumInteritemSpacing = 0;
// 关键:上下内边距让垂直方向刚好放一个 cell,确保只有一行
CGFloat verticalInset = (self.collectionView.frame.size.height - self.itemSize.height) * 0.5;
// 左右内边距让首尾 cell 也能居中
CGFloat horizontalInset = (self.collectionView.frame.size.width - self.itemSize.width) * 0.5;
self.sectionInset = UIEdgeInsetsMake(verticalInset, horizontalInset, verticalInset, horizontalInset);
}
#pragma mark - 2. 强制每个 cell 垂直居中(确保单行的核心)
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewLayoutAttributes *attrs = [super layoutAttributesForItemAtIndexPath:indexPath];
// 强制 y 居中:y = (collectionView 高度 - cell 高度) / 2
CGRect frame = attrs.frame;
frame.origin.y = (self.collectionView.frame.size.height - frame.size.height) * 0.5;
attrs.frame = frame;
return attrs;
}
#pragma mark - 3. 排布 rect 范围内的控件(缩放微调)
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect {
NSMutableArray *result = [NSMutableArray array];
// 用 super 确定哪些元素在 rect 范围内
NSArray *superArray = [super layoutAttributesForElementsInRect:rect];
for (UICollectionViewLayoutAttributes *attrs in superArray) {
if (attrs.representedElementCategory == UICollectionElementCategoryCell) {
// 用重写后的方法获取居中属性
UICollectionViewLayoutAttributes *newAttrs = [self layoutAttributesForItemAtIndexPath:attrs.indexPath];
[result addObject:newAttrs];
} else {
[result addObject:attrs];
}
}
// 当前可见区域中心点 x
CGFloat centerX = self.collectionView.contentOffset.x + self.collectionView.frame.size.width * 0.5;
// 缩放(只对 cell)
for (UICollectionViewLayoutAttributes *attrs in result) {
if (attrs.representedElementCategory != UICollectionElementCategoryCell) {
continue;
}
CGFloat delta = ABS(attrs.center.x - centerX);
CGFloat scale = 1 - delta / self.collectionView.frame.size.width * self.scaleFactor;
scale = MAX(scale, self.minScale); // 限制最小缩放
attrs.transform = CGAffineTransformMakeScale(scale, scale);
}
return result;
}
#pragma mark - 4. 滚动时重新布局
- (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds {
return YES;
}
#pragma mark - 5. 停止滚动时吸附对齐
- (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity {
CGRect rect;
rect.origin.y = 0;
rect.origin.x = proposedContentOffset.x;
rect.size = self.collectionView.frame.size;
NSArray *array = [self layoutAttributesForElementsInRect:rect];
CGFloat centerX = proposedContentOffset.x + self.collectionView.frame.size.width * 0.5;
CGFloat minDelta = MAXFLOAT;
for (UICollectionViewLayoutAttributes *attrs in array) {
if (attrs.representedElementCategory != UICollectionElementCategoryCell) {
continue;
}
if (ABS(minDelta) > ABS(attrs.center.x - centerX)) {
minDelta = attrs.center.x - centerX;
}
}
proposedContentOffset.x += minDelta;
return proposedContentOffset;
}
@end
四、使用方法(ViewController)
#import "HWCoverFlowLayout.h"
@interface ViewController () <UICollectionViewDataSource, UICollectionViewDelegate>
@property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, strong) NSArray *dataArray;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.dataArray = @[@"1", @"2", @"3", @"4", @"5", @"6", @"7"];
// 创建自定义布局
HWCoverFlowLayout *layout = [[HWCoverFlowLayout alloc] init];
layout.itemSize = CGSizeMake(200, 300); // cell 大小
layout.minimumLineSpacing = 30; // 横向间距
layout.scaleFactor = 0.5; // 缩放因子
layout.minScale = 0.6; // 最小缩放比例
// collectionView 高度建议 = cell 高度(或略大),避免垂直空间过多
CGFloat collectionHeight = 300; // 与 itemSize.height 一致
CGRect collectionFrame = CGRectMake(0, 100, self.view.bounds.size.width, collectionHeight);
self.collectionView = [[UICollectionView alloc] initWithFrame:collectionFrame collectionViewLayout:layout];
self.collectionView.backgroundColor = [UIColor colorWithWhite:0.95 alpha:1.0];
self.collectionView.dataSource = self;
self.collectionView.delegate = self;
self.collectionView.showsHorizontalScrollIndicator = NO;
self.collectionView.showsVerticalScrollIndicator = NO;
[self.collectionView registerClass:[UICollectionViewCell class] forCellWithReuseIdentifier:@"Cell"];
[self.view addSubview:self.collectionView];
}
#pragma mark - UICollectionViewDataSource
- (NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return self.dataArray.count;
}
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"Cell" forIndexPath:indexPath];
// 随机颜色
CGFloat hue = (indexPath.item * 0.1);
cell.backgroundColor = [UIColor colorWithHue:hue saturation:0.6 brightness:0.9 alpha:1.0];
cell.layer.cornerRadius = 12;
cell.layer.masksToBounds = YES;
return cell;
}
#pragma mark - 获取当前居中的 cell
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
CGFloat centerX = scrollView.contentOffset.x + scrollView.frame.size.width * 0.5;
CGPoint centerPoint = CGPointMake(centerX, scrollView.frame.size.height * 0.5);
NSIndexPath *indexPath = [self.collectionView indexPathForItemAtPoint:centerPoint];
NSLog(@"当前居中的 cell: %ld", (long)indexPath.item);
}
@end
collectionView 高度建议:将 collectionView 的高度设置为与
itemSize.height一致(或略大),避免垂直空间过多导致 FlowLayout 计算异常。自定义布局中的verticalInset会自动处理居中。
五、确保单行的三重保障总结
| 保障手段 | 实现方式 | 作用 |
|---|---|---|
| 第一重:内边距控制 | sectionInset.top/bottom = (collectionView 高 - cell 高) / 2 |
让垂直方向刚好容纳一个 cell,FlowLayout 不会换行 |
| 第二重:间距归零 | minimumInteritemSpacing = 0 |
同一列内 cell 纵向间距为 0,避免意外堆叠 |
| 第三重:强制居中 | 重写 layoutAttributesForItemAtIndexPath: 设 y 居中 |
即使前两重有偏差,也强制所有 cell 在同一水平线 |
六、Swift 版本对照
自定义布局(Swift)
import UIKit
class CoverFlowLayout: UICollectionViewFlowLayout {
var scaleFactor: CGFloat = 0.4
var minScale: CGFloat = 0.6
override func prepare() {
super.prepare()
scrollDirection = .horizontal
if itemSize == .zero {
itemSize = CGSize(width: 200, height: 300)
}
minimumLineSpacing = 20
minimumInteritemSpacing = 0
// 上下内边距确保单行
let verticalInset = (collectionView!.frame.height - itemSize.height) * 0.5
let horizontalInset = (collectionView!.frame.width - itemSize.width) * 0.5
sectionInset = UIEdgeInsets(top: verticalInset, left: horizontalInset,
bottom: verticalInset, right: horizontalInset)
}
// 强制每个 cell 垂直居中
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard let attrs = super.layoutAttributesForItem(at: indexPath) else { return nil }
var frame = attrs.frame
frame.origin.y = (collectionView!.frame.height - frame.size.height) * 0.5
attrs.frame = frame
return attrs
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var result: [UICollectionViewLayoutAttributes] = []
guard let superArray = super.layoutAttributesForElements(in: rect) else { return nil }
for attrs in superArray {
if attrs.representedElementCategory == .cell {
if let newAttrs = layoutAttributesForItem(at: attrs.indexPath) {
result.append(newAttrs)
}
} else {
result.append(attrs)
}
}
let centerX = collectionView!.contentOffset.x + collectionView!.frame.width * 0.5
for attrs in result {
guard attrs.representedElementCategory == .cell else { continue }
let delta = abs(attrs.center.x - centerX)
var scale = 1 - delta / collectionView!.frame.width * scaleFactor
scale = max(scale, minScale)
attrs.transform = CGAffineTransform(scaleX: scale, y: scale)
}
return result
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return true
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint) -> CGPoint {
var proposed = proposedContentOffset
let rect = CGRect(x: proposed.x, y: 0,
width: collectionView!.frame.width,
height: collectionView!.frame.height)
guard let array = layoutAttributesForElements(in: rect) else { return proposed }
let centerX = proposed.x + collectionView!.frame.width * 0.5
var minDelta = CGFloat.greatestFiniteMagnitude
for attrs in array {
guard attrs.representedElementCategory == .cell else { continue }
if abs(minDelta) > abs(attrs.center.x - centerX) {
minDelta = attrs.center.x - centerX
}
}
proposed.x += minDelta
return proposed
}
}
使用(Swift)
let layout = CoverFlowLayout()
layout.itemSize = CGSize(width: 200, height: 300)
layout.scaleFactor = 0.5
let collectionView = UICollectionView(frame: CGRect(x: 0, y: 100, width: view.bounds.width, height: 300),
collectionViewLayout: layout)
collectionView.dataSource = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
view.addSubview(collectionView)
七、总结
- 多行问题根因:横向滚动的 FlowLayout 在 collectionView 高度大于 cell 高度时,会在垂直方向堆叠多个 cell(同一列放多个),导致多行。
- 三重保障确保单行:①
sectionInset.top/bottom= (collectionView 高 - cell 高) / 2,让垂直方向刚好放一个 cell;②minimumInteritemSpacing = 0,避免同列纵向堆叠;③ 重写layoutAttributesForItemAtIndexPath:强制每个 cell 的 y 坐标居中。 - layoutAttributesForElementsInRect: 注意:不能直接返回 super 的结果(super 的 cell y 可能不居中),需要遍历调用重写的
layoutAttributesForItemAtIndexPath:获取居中属性,再进行缩放。 - 缩放公式:
scale = 1 - delta / width * scaleFactor,用minScale限制最小比例防止 cell 消失。 - 吸附效果:
targetContentOffsetForProposedContentOffset:计算最近 cell 中心与预计中心的差值,调整偏移量实现停止时自动对齐。 - collectionView 高度建议:与 itemSize.height 一致(或略大),配合自定义布局的内边距自动居中。
将来的你会感谢今天如此努力的你!
版权声明:本文为博主原创文章,未经博主允许不得转载。

浙公网安备 33010602011771号