如何用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
相关产品推荐
相关产品推荐

