You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何解决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)
    }
}

关键修复说明

  1. 页面宽度修正:将minimumInteritemSpacing替换为minimumLineSpacing,符合横向滚动时的间距定义
  2. 页码计算基准修正:用proposedContentOffset.x替代当前contentOffset.x,确保页码判断基于滚动结束后的预期位置
  3. 移除多页跳转逻辑:删除flickedPages相关代码,无论滚动速度快慢,都只会切换到相邻的单元格

内容的提问来源于stack exchange,提问作者Emre Değirmenci

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.23 02:02:10