iOS开发基础67-UICollectionView 自定义流水布局深度解析:横向滚动中间放大效果

UICollectionView 自定义流水布局深度解析:横向滚动中间放大效果

本文系统梳理 UICollectionView 自定义布局:通过继承 UICollectionViewFlowLayout 重写核心方法,实现横向滚动卡片中间放大、两侧缩小、停止滚动自动吸附到中心的效果。重点修复多行问题:通过内边距控制 + 重写 layoutAttributesForItemAtIndexPath: 强制所有 cell 垂直居中,确保 collectionView 只有一行。


一、问题分析:为什么确保 collectionView 只有一行?

在横向滚动的 UICollectionViewFlowLayout 中:

  • minimumLineSpacing 是列与列之间的间距(横向)
  • minimumInteritemSpacing 是同一列中 cell 之间的间距(纵向)

当 collectionView 的高度大于 cell 高度时,FlowLayout 会在垂直方向尝试放下多个 cell(同一列堆叠多个),导致出现多行,不是我们要的效果。

确保只有一行的两个关键手段:

  1. 内边距控制:sectionInset.top/bottom = (collectionView 高度 - cell 高度) / 2,让垂直方向刚好容纳一个 cell。
  2. 强制居中(双重保险):重写 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 一致(或略大),配合自定义布局的内边距自动居中。

posted @ 2015-10-16 22:14  Mr.陳  阅读(3879)  评论(1)    收藏  举报