如何为水平单行UICollectionView实现部分单元格堆叠式自定义间距?
实现带堆叠压缩效果的水平UICollectionView
这个需求我之前做过类似的场景,核心就是自定义UICollectionView的布局,让单元格数量超过指定阈值后,前X个单元格通过负间距实现重叠堆叠,后面的保持正常间距。下面给你一步步拆解实现思路和代码:
核心思路
系统的UICollectionViewFlowLayout没法直接实现这种堆叠效果,所以我们需要自定义一个布局类,重写布局计算逻辑:
- 提前计算每个单元格的
frame,当总单元格数超过设定的阈值时,对前X个单元格使用负间距,让它们相互重叠; - 后面的单元格依然使用正常间距,保持原有布局;
- 正确计算
collectionViewContentSize,确保滚动范围准确。
具体实现步骤
1. 自定义布局类
创建一个继承自UICollectionViewFlowLayout的子类,封装堆叠逻辑,同时暴露可配置的参数方便调整:
class StackedHorizontalLayout: UICollectionViewFlowLayout { // 可配置参数,根据需求调整 var normalSpacing: CGFloat = 16 // 正常单元格间距 var stackCount: Int = 3 // 需要堆叠的前X个单元格数量 var totalThreshold: Int = 5 // 触发堆叠的总单元格阈值 var stackOverlapRatio: CGFloat = 0.7 // 堆叠重叠比例(0~1,值越大重叠越多) private var cachedLayoutAttributes: [UICollectionViewLayoutAttributes] = [] override func prepare() { super.prepare() guard let collectionView = collectionView else { return } cachedLayoutAttributes.removeAll() let totalItems = collectionView.numberOfItems(inSection: 0) let itemSize = self.itemSize var currentXOffset: CGFloat = sectionInset.left for itemIndex in 0..<totalItems { let indexPath = IndexPath(item: itemIndex, section: 0) let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) // 判断当前单元格是否需要参与堆叠 let shouldStack = totalItems > totalThreshold && itemIndex < stackCount // 堆叠时用负间距实现重叠,正常情况用设定的间距 let spacing = shouldStack ? -(itemSize.width * stackOverlapRatio) : normalSpacing // 设置单元格frame attributes.frame = CGRect( x: currentXOffset, y: sectionInset.top, width: itemSize.width, height: itemSize.height ) cachedLayoutAttributes.append(attributes) // 更新下一个单元格的X偏移量(最后一个单元格不需要加间距) if itemIndex != totalItems - 1 { currentXOffset += itemSize.width + spacing } } } // 返回指定区域内的布局属性 override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return cachedLayoutAttributes.filter { $0.frame.intersects(rect) } } // 返回指定位置的单元格布局属性 override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard indexPath.item < cachedLayoutAttributes.count else { return nil } return cachedLayoutAttributes[indexPath.item] } // 计算滚动内容的总尺寸 override var collectionViewContentSize: CGSize { guard let collectionView = collectionView, !cachedLayoutAttributes.isEmpty else { return .zero } let lastItemFrame = cachedLayoutAttributes.last!.frame return CGSize( width: lastItemFrame.maxX + sectionInset.right, height: collectionView.bounds.height - sectionInset.top - sectionInset.bottom ) } }
2. 在ViewController中使用布局
配置UICollectionView并测试动态添加单元格的效果:
class DemoViewController: UIViewController { private var cellCount = 5 private let stackedLayout = StackedHorizontalLayout() private lazy var collectionView: UICollectionView = { let cv = UICollectionView(frame: .zero, collectionViewLayout: stackedLayout) cv.translatesAutoresizingMaskIntoConstraints = false cv.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "StackedCell") cv.dataSource = self cv.showsHorizontalScrollIndicator = false cv.backgroundColor = .white return cv }() override func viewDidLoad() { super.viewDidLoad() setupUI() configureLayout() } private func setupUI() { view.backgroundColor = .white view.addSubview(collectionView) // 添加添加按钮 let addButton = UIButton(type: .system) addButton.translatesAutoresizingMaskIntoConstraints = false addButton.setTitle("添加单元格", for: .normal) addButton.addTarget(self, action: #selector(addNewCell), for: .touchUpInside) view.addSubview(addButton) // 布局约束 NSLayoutConstraint.activate([ collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40), collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), collectionView.heightAnchor.constraint(equalToConstant: 100), addButton.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 30), addButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), addButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), addButton.heightAnchor.constraint(equalToConstant: 44) ]) } private func configureLayout() { stackedLayout.scrollDirection = .horizontal stackedLayout.itemSize = CGSize(width: 80, height: 80) stackedLayout.normalSpacing = 16 stackedLayout.stackCount = 3 stackedLayout.totalThreshold = 5 stackedLayout.stackOverlapRatio = 0.7 // 重叠70%,可按需调整 } @objc private func addNewCell() { cellCount += 1 // 用performBatchUpdates获得平滑的添加动画 collectionView.performBatchUpdates { self.collectionView.insertItems(at: [IndexPath(item: self.cellCount - 1, section: 0)]) } completion: { _ in // 可选:自动滚动到最后一个单元格 let lastIndexPath = IndexPath(item: self.cellCount - 1, section: 0) self.collectionView.scrollToItem(at: lastIndexPath, at: .right, animated: true) } } } // MARK: - UICollectionViewDataSource extension DemoViewController: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return cellCount } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "StackedCell", for: indexPath) // 给不同单元格设置不同颜色,方便区分堆叠效果 let hue = CGFloat(indexPath.item) / CGFloat(cellCount) cell.backgroundColor = UIColor(hue: hue, saturation: 0.8, brightness: 0.8, alpha: 1) cell.layer.cornerRadius = 8 return cell } }
额外优化点
- 堆叠方向调整:如果需要让最后X个单元格堆叠,只需要修改
shouldStack的判断条件为totalItems > totalThreshold && itemIndex >= totalItems - stackCount即可; - 动态调整重叠比例:可以根据单元格数量动态调整重叠程度,比如数量越多重叠越多;
- 性能优化:如果单元格数量很大,可以在
prepare()方法中做缓存逻辑,避免重复计算; - 交互优化:如果堆叠的单元格需要响应点击,可以调整
zIndex,让最上层的单元格优先响应事件。
内容的提问来源于stack exchange,提问作者DJB
相关产品推荐
相关产品推荐

