如何解决UICollectionView轮播卡片滚动跳格并实现滚动后居中
UICollectionView轮播布局逐单元格滚动修复方案
问题现象
UICollectionView实现了视差滚动效果,当前激活的单元格悬浮在前后单元格上方,但滚动时出现跳格问题:从第一个黑色单元格左右滚动会直接跳到第四个青色单元格,无法实现逐单元格平滑切换。
问题根源
问题出在自定义布局CarouselFlowLayout的targetContentOffset方法中,核心计算逻辑存在三处错误:
- 错误使用
minimumInteritemSpacing计算页面宽度,横向滚动时应该用minimumLineSpacing(行间距才是单元格之间的横向间距) - 基于当前
contentOffset计算页码,而非滚动结束后的提议偏移量,导致页码判断偏差 - 快速滚动时的
flickedPages逻辑会触发多页跳转,违背逐单元格滚动的需求
修复后的完整代码
替换原CarouselFlowLayout中的targetContentOffset方法,并优化部分过时API调用:
extension UIScrollView { var visibleRect: CGRect { CGRect(origin: contentOffset, size: bounds.size) } } class ViewController: UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource { @IBOutlet private var collectionView: UICollectionView! private var images = [ UIColor.black, UIColor.purple, UIColor.yellow, UIColor.cyan, UIColor.red, UIColor.blue ] override func viewDidLoad() { super.viewDidLoad() collectionView.register( UINib( nibName: "FooCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: "FooCollectionViewCell") collectionView.delegate = self collectionView.dataSource = self let layout = CarouselFlowLayout() layout.itemSize = CGSize(width: 350, height: 350) layout.scrollDirection = .horizontal layout.sideItemAlpha = 0.8 layout.sideItemScale = 0.8 layout.spacingMode = CarouselFlowLayoutSpacingMode.overlap(visibleOffset: collectionView.frame.width * 0.5) collectionView.setCollectionViewLayout(layout, animated: false) } func numberOfSections(in collectionView: UICollectionView) -> Int { return 1 } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return images.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FooCollectionViewCell", for: indexPath) as? FooCollectionViewCell else { return FooCollectionViewCell() } cell.set(item: images[indexPath.row]) cell.layer.cornerRadius = 8.0 return cell } } public enum CarouselFlowLayoutSpacingMode { case fixed(spacing: CGFloat) case overlap(visibleOffset: CGFloat) } class CarouselFlowLayout: UICollectionViewFlowLayout { struct LayoutState { var size: CGSize var direction: UICollectionView.ScrollDirection func isEqual(otherState: LayoutState) -> Bool { return size.equalTo(otherState.size) && direction == otherState.direction } } var sideItemScale: CGFloat = 0.6 var sideItemAlpha: CGFloat = 0.6 var spacingMode = CarouselFlowLayoutSpacingMode.fixed(spacing: 90) private var state = LayoutState(size: .zero, direction: .horizontal) override func prepare() { super.prepare() let currentState = LayoutState(size: collectionView!.bounds.size, direction: scrollDirection) if !state.isEqual(otherState: currentState) { setupCollectionView() updateLayout() state = currentState } } private func setupCollectionView() { guard let collectionView = collectionView else { return } if collectionView.decelerationRate != .fast { collectionView.decelerationRate = .fast } } private func updateLayout() { guard let collectionView = collectionView else { return } let collectionSize = collectionView.bounds.size let yInset = (collectionSize.height - itemSize.height) / 2 let xInset = (collectionSize.width - itemSize.width) / 2 sectionInset = UIEdgeInsets(top: yInset, left: xInset, bottom: yInset, right: xInset) let side = itemSize.width let scaledItemOffset = (side - side * sideItemScale) / 2 switch spacingMode { case .fixed(let spacing): minimumLineSpacing = spacing - scaledItemOffset case .overlap(let visibleOffset): let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset let inset = xInset minimumLineSpacing = inset - fullSizeSideItemOverlap } } override public func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true } override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let superAttributes = super.layoutAttributesForElements(in: rect), let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes] else { return nil } return attributes.map(transformLayoutAttributes) } private func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { guard let collectionView = collectionView else { return attributes } let collectionCenter = collectionView.frame.size.width / 2 let offset = collectionView.contentOffset.x let normalizedCenter = attributes.center.x - offset let maxDistance = itemSize.width + minimumLineSpacing let distance = min(abs(collectionCenter - normalizedCenter), maxDistance) let ratio = (maxDistance - distance) / maxDistance let alpha = ratio * (1 - sideItemAlpha) + sideItemAlpha let scale = ratio * (1 - sideItemScale) + sideItemScale attributes.alpha = alpha let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let dist = attributes.frame.midX - visibleRect.midX var transform = CATransform3DScale(CATransform3DIdentity, scale, scale, 1) transform = CATransform3DTranslate(transform, 0, 0, -abs(dist / 1000)) attributes.transform3D = transform return attributes } override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint { guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) } // 修复1:使用minimumLineSpacing计算单页宽度,匹配横向滚动的间距逻辑 let pageWidth = itemSize.width + minimumLineSpacing // 修复2:基于提议的偏移量计算页码,确保判断的是滚动结束后的目标位置 let approximatePage = proposedContentOffset.x / pageWidth var targetPage: CGFloat if velocity.x == 0 { // 无滚动速度时,取最近的页码 targetPage = round(approximatePage) } else { // 根据滚动方向取整,保证只切换到相邻单元格 targetPage = velocity.x > 0 ? ceil(approximatePage) : floor(approximatePage) } // 修复3:移除多页跳转逻辑,强制逐单元格滚动 let newHorizontalOffset = targetPage * pageWidth - collectionView.contentInset.left return CGPoint(x: newHorizontalOffset, y: proposedContentOffset.y) } }
关键修复说明
- 页面宽度修正:将
minimumInteritemSpacing替换为minimumLineSpacing,符合横向滚动时的间距定义 - 页码计算基准修正:用
proposedContentOffset.x替代当前contentOffset.x,确保页码判断基于滚动结束后的预期位置 - 移除多页跳转逻辑:删除
flickedPages相关代码,无论滚动速度快慢,都只会切换到相邻的单元格
内容的提问来源于stack exchange,提问作者Emre Değirmenci
相关产品推荐
相关产品推荐

