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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.20 02:46:09