iOS中是否可通过UIGestureRecognizer实现类似Android的Fling检测?
iOS自定义滚动视图的Fling惯性实现方案
核心思路
iOS系统UIScrollView的Fling(惯性滑动)效果,核心是基于手势结束前的连续速度采样,而非单一的最后偏移量。要模拟这种效果,需要在手势拖动过程中记录触摸点的位置与时间戳,计算松开瞬间的瞬时速度,再通过逐帧更新实现减速运动直到停止。
关键计算逻辑
1. 手势采样记录
在UIPanGestureRecognizer的.began和.changed状态下,持续记录最近10个左右触摸点的位置和对应的时间戳(用CACurrentMediaTime()获取高精度时间),避免单点数据抖动影响速度计算。
2. 瞬时速度计算
取最后2-3个有效采样点,通过位移差除以时间差得到每秒像素速度(单位:points/second),公式如下:
速度v = (当前位置 - 上一位置) / (当前时间 - 上一时间)
如果采样点数量不足2个,直接返回0速度。
3. 减速运动模拟
采用和系统UIScrollView一致的指数衰减减速模型,每帧对速度乘以一个减速系数(系统默认正常减速率为0.998,快速减速为0.99),直到速度小于设定的阈值(比如10 points/second)时停止惯性动画。
代码实现示例(Swift)
定义核心变量
import UIKit class CustomScrollView: UIView { // 手势采样点数组,存储位置和时间戳 private var gestureSamples: [(position: CGPoint, timestamp: TimeInterval)] = [] // 最大采样数,避免数组过大 private let maxSampleCount = 10 // 驱动惯性动画的显示链路 private var displayLink: CADisplayLink? // 当前惯性速度 private var currentVelocity = CGPoint.zero // 减速系数,匹配系统UIScrollView的正常减速率 private let decelerationRate: CGFloat = UIScrollView.DecelerationRate.normal.rawValue // 速度阈值,低于此值停止惯性 private let velocityThreshold: CGFloat = 10 // 内容视图,自定义滚动的内容容器 private let contentView = UIView() override init(frame: CGRect) { super.init(frame: frame) setupView() } required init?(coder: NSCoder) { super.init(coder: coder) setupView() } private func setupView() { addSubview(contentView) let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) addGestureRecognizer(panGesture) } }
手势处理逻辑
extension CustomScrollView { @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { let currentPosition = gesture.translation(in: self) let currentTime = CACurrentMediaTime() switch gesture.state { case .began: // 清空旧采样,停止之前的惯性动画 gestureSamples.removeAll() stopFling() gestureSamples.append((position: currentPosition, timestamp: currentTime)) case .changed: // 添加新采样,超出数量则移除最早的 gestureSamples.append((position: currentPosition, timestamp: currentTime)) if gestureSamples.count > maxSampleCount { gestureSamples.removeFirst() } // 更新实时拖动的内容偏移 updateContentOffset(with: currentPosition) case .ended, .cancelled: // 计算惯性速度,达到阈值则启动动画 currentVelocity = calculateFlingVelocity() if currentVelocity.magnitude > velocityThreshold { startFlingAnimation() } default: break } } // 计算松开瞬间的瞬时速度 private func calculateFlingVelocity() -> CGPoint { guard gestureSamples.count >= 2 else { return .zero } // 取最后两个采样点计算,也可以取最后3个的平均提升稳定性 let lastSample = gestureSamples[gestureSamples.count - 1] let prevSample = gestureSamples[gestureSamples.count - 2] let timeDiff = lastSample.timestamp - prevSample.timestamp guard timeDiff > 0 else { return .zero } let xVelocity = (lastSample.position.x - prevSample.position.x) / CGFloat(timeDiff) let yVelocity = (lastSample.position.y - prevSample.position.y) / CGFloat(timeDiff) return CGPoint(x: xVelocity, y: yVelocity) } }
惯性动画实现
extension CustomScrollView { private func startFlingAnimation() { displayLink?.invalidate() displayLink = CADisplayLink(target: self, selector: #selector(updateFling)) displayLink?.add(to: .main, forMode: .common) } private func stopFling() { displayLink?.invalidate() displayLink = nil currentVelocity = .zero } @objc private func updateFling() { // 应用指数衰减减速 currentVelocity.x *= decelerationRate currentVelocity.y *= decelerationRate // 计算当前帧的位移(基于60Hz刷新率的帧时长) let frameDuration = 1.0 / 60.0 let deltaX = currentVelocity.x * CGFloat(frameDuration) let deltaY = currentVelocity.y * CGFloat(frameDuration) // 更新内容偏移(增量更新) updateContentOffset(withDelta: CGPoint(x: deltaX, y: deltaY)) // 速度低于阈值则停止惯性 if currentVelocity.magnitude < velocityThreshold { stopFling() } } // 实时拖动的偏移更新(根据自己的布局逻辑调整) private func updateContentOffset(with position: CGPoint) { contentView.transform = CGAffineTransform(translationX: position.x, y: position.y) } // 惯性动画的增量偏移更新(根据自己的布局逻辑调整) private func updateContentOffset(withDelta delta: CGPoint) { let currentTransform = contentView.transform contentView.transform = currentTransform.translatedBy(x: delta.x, y: delta.y) } }
额外优化点
- 边界回弹:当内容滚动到边界时,修改速度方向并乘以回弹系数(比如0.5),模拟系统的弹性效果。
- 采样优化:取最后3个采样点计算平均速度,进一步降低手势抖动的影响。
- 帧率适配:不要硬编码60Hz的帧时长,可通过
displayLink?.timestamp和上一帧的时间戳计算实际帧间隔,适配不同刷新率的屏幕。
内容的提问来源于stack exchange,提问作者Brunni
相关产品推荐
相关产品推荐

