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

如何用UIPanGestureRecognizer实现iOS相册式UICollectionView变速滚动?

嘿,这个需求我之前做类似相册功能的时候刚好研究过,完全能实现,而且可以做到流畅不卡顿的效果,下面给你拆解思路和具体实现方式:

核心思路

原生相册的边缘滚动逻辑,本质是根据手指在边缘区域的偏移程度动态计算滚动速度,再逐帧更新集合视图的contentOffset来模拟滚动。直接用scrollToVisibleRect或者简单的UI动画块很难做到原生的流畅感,更推荐用和屏幕刷新率同步的CADisplayLink来驱动滚动,这是避免卡顿的关键。

一、先禁用UICollectionView的默认滚动

要自己接管滚动逻辑,首先得关掉系统自带的滚动:

  • 把UICollectionView的isScrollEnabled设为false,这样系统的滚动手势就不会干扰我们的自定义手势。
  • 添加自定义的UIPanGestureRecognizer,并设置它的delegate,确保能正常接收手势事件(比如和集合视图的点击手势共存)。

二、实现边缘滚动的核心逻辑

1. 识别边缘触发区域

在pan手势的回调里,获取手指当前位置,判断是否处于滚动触发边缘(比如上下各80pt的区域,这个阈值可以自己调整)。然后计算边缘偏移量:比如手指在顶部边缘时,偏移量是“触发阈值 - 手指y坐标”,这个值越大,说明离边缘越近,滚动速度应该越快。

2. 用CADisplayLink驱动流畅滚动

CADisplayLink会和屏幕刷新率同步(每秒60次),在它的回调里逐帧更新contentOffset,能保证滚动的流畅性:

  • 当手势在边缘时,根据偏移量乘以一个速度系数,得到当前的滚动速度,每帧更新contentOffset.y(垂直滚动)。
  • 当手势结束时,模拟惯性减速效果(比如每次把速度乘以0.9,直到速度趋近于0),避免滚动突然停止,更贴近原生体验。

3. 避开易卡顿的实现方式

  • 不要用UIView动画块设置contentOffset:动画块的帧率无法和屏幕完全同步,而且手势变化时容易出现动画叠加、卡顿的问题。
  • 不推荐用scrollToVisibleRect:这个API适合一次性滚动到指定区域,无法满足随手势动态变化的持续滚动需求。

三、简化版代码示例

class PhotoCollectionViewController: UICollectionViewController, UIGestureRecognizerDelegate {
    private var panGesture: UIPanGestureRecognizer!
    private var displayLink: CADisplayLink!
    private var scrollVelocity: CGFloat = 0.0
    private let edgeThreshold: CGFloat = 80.0 // 边缘触发区域高度,可调整
    private let speedMultiplier: CGFloat = 0.3 // 速度系数,手感调试用

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.isScrollEnabled = false
        
        // 初始化自定义pan手势
        panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        panGesture.delegate = self
        view.addGestureRecognizer(panGesture)
        
        // 初始化CADisplayLink,绑定滚动更新方法
        displayLink = CADisplayLink(target: self, selector: #selector(updateScroll))
        displayLink.add(to: .main, forMode: .common)
        displayLink.isPaused = true // 默认暂停,只有手势触发时启动
    }

    @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
        let location = gesture.location(in: view)
        let bounds = view.bounds
        
        switch gesture.state {
        case .began, .changed:
            var edgeOffset: CGFloat = 0.0
            // 判断是否在顶部边缘
            if location.y < edgeThreshold {
                edgeOffset = edgeThreshold - location.y
                scrollVelocity = edgeOffset * speedMultiplier
            }
            // 判断是否在底部边缘
            else if location.y > bounds.height - edgeThreshold {
                edgeOffset = location.y - (bounds.height - edgeThreshold)
                scrollVelocity = -edgeOffset * speedMultiplier
            }
            // 不在边缘区域时停止滚动
            else {
                scrollVelocity = 0.0
                displayLink.isPaused = true
                return
            }
            displayLink.isPaused = false
            
        case .ended, .cancelled:
            // 模拟惯性减速,逐渐停止滚动
            let decelerationTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { [weak self] timer in
                guard let self = self else { return }
                self.scrollVelocity *= 0.9
                if abs(self.scrollVelocity) < 0.1 {
                    self.scrollVelocity = 0.0
                    self.displayLink.isPaused = true
                    timer.invalidate()
                }
            }
            decelerationTimer.tolerance = 0.005
            
        default:
            scrollVelocity = 0.0
            displayLink.isPaused = true
        }
    }

    @objc private func updateScroll() {
        guard scrollVelocity != 0 else { return }
        var newOffset = collectionView.contentOffset
        newOffset.y += scrollVelocity
        
        // 限制滚动范围,避免超出内容边界
        let maxScrollY = max(collectionView.contentSize.height - collectionView.bounds.height, 0)
        newOffset.y = max(0, min(newOffset.y, maxScrollY))
        
        if newOffset != collectionView.contentOffset {
            collectionView.contentOffset = newOffset
        } else {
            // 已经滚动到边界,停止滚动
            scrollVelocity = 0.0
            displayLink.isPaused = true
        }
    }
    
    // 允许pan手势和其他手势(如item点击)共存
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
}

四、优化建议

  • 调试手感:调整edgeThreshold(触发区域大小)和speedMultiplier(速度系数),让滚动手感更贴近原生相册。
  • 支持横向滚动:如果是横向相册,只需要把代码中y相关的计算换成x即可。
  • 性能优化:在updateScroll里尽量减少不必要的计算,确保每帧处理时间控制在16ms以内(保证60fps)。

内容的提问来源于stack exchange,提问作者simonthumper

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.05.14 08:21:23