手势识别与动画

75 阅读4分钟

一、触摸/动画总流程

  1. 事件进入 View:onTouchEvent(ev)

  2. 手势识别:把 ev 同时喂给

    • gestureDetector.onTouchEvent(ev)(单击/长按/双击/滚动)
    • scaleDetector.onTouchEvent(ev)(双指缩放)
  3. 速度采样:velocityTracker.addMovement(ev);在 UP 调 computeCurrentVelocity(1000) 拿 px/s

  4. 启动动画:用 OverScroller.fling(...)(或 springBack(...))

  5. 驱动绘制:computeScroll() 中 if (scroller.computeScrollOffset()) → 更新位置/缩放 → postInvalidateOnAnimation()

  6. 边界/回弹:给 fling 指定 min/max 边界与 overScroll,或在动画结束时 springBack(...)

关键约定

  • GestureDetector.onDown() 必须返回 true,否则收不到后续手势回调。

  • 拖拽/缩放一开始调用 requestDisallowInterceptTouchEvent(true),避免父容器半路拦截。

  • postInvalidateOnAnimation() 比 invalidate() 更平滑(按 60/120Hz vsync 刷新)。


二、三个识别器:用法与要点

1) GestureDetector(单击/长按/双击/滚动)

  • onDown(ev):返回 true 才能继续收到 onScroll/onFling/onLongPress/onDoubleTap。

  • onScroll(e1, e2, dx, dy):适合做“拖拽平移”。注意 dx/dy 是手指移动量,通常 x -= dx; y -= dy。

  • onFling 也会给速度,但实战更推荐 VelocityTracker,控制更细。

2) ScaleGestureDetector(双指缩放)

  • 焦点:focusX/focusY,用于实现“以手指中心为锚点缩放”。

  • 模式:onScaleBegin() → onScale()(可多次)→ onScaleEnd();onScaleBegin() 返回 true 才会进入缩放。

  • 锚点缩放两种作法:

    • 矩阵法:matrix.postScale(scale, scale, focusX, focusY);

    • 几何法:同时改 scale 与 translation,让 (focus → 内容中心) 的向量按比例缩放。

3) VelocityTracker + OverScroller(速度与动画)

  • 速度:手指抬起(ACTION_UP)时
tracker.computeCurrentVelocity(1000)  // px/s
val vx = tracker.xVelocity
val vy = tracker.yVelocity
  • 惯性:
scroller.fling(startX, startY, vx.toInt(), vy.toInt(),
               minX, maxX, minY, maxY, overX, overY)
postInvalidateOnAnimation()
  • 驱动:
override fun computeScroll() {
    if (scroller.computeScrollOffset()) {
        x = scroller.currX; y = scroller.currY
        postInvalidateOnAnimation()
    } else { // 结束,可选 springBack 收口
        if (scroller.springBack(x, y, minX, maxX, minY, maxY)) {
            postInvalidateOnAnimation()
        }
    }
}

三、可拖拽 + 双指缩放 + fling + 回弹(Kotlin)

下面是最小可运行版(用“圆球”演示,逻辑清晰、好调参):

class GestureAnimView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {

    // --- 绘制 ---
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#4F7FFF") }
    private val border = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLACK; style = Paint.Style.STROKE; strokeWidth = 2f; alpha = 64
    }

    // --- 状态 ---
    private var cx = 0f
    private var cy = 0f
    private var baseR = 120f
    private var scale = 1f
    private val minScale = 0.5f
    private val maxScale = 3f
    private val r get() = baseR * scale

    private val vc = ViewConfiguration.get(context)
    private val minFling = vc.scaledMinimumFlingVelocity
    private val scroller = OverScroller(context)
    private var tracker: VelocityTracker? = null
    private var dragging = false

    // --- GestureDetector ---
    private val gDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            if (!scroller.isFinished) scroller.abortAnimation()
            parent?.requestDisallowInterceptTouchEvent(true)
            return true
        }
        override fun onScroll(e1: MotionEvent, e2: MotionEvent, dx: Float, dy: Float): Boolean {
            if (!sDetector.isInProgress) { // 非缩放中,才平移
                cx -= dx
                cy -= dy
                clampInside()
                postInvalidateOnAnimation()
            }
            return true
        }
        override fun onDoubleTap(e: MotionEvent): Boolean {
            // 双击复位
            scale = 1f
            cx = width / 2f; cy = height / 2f
            postInvalidateOnAnimation()
            return true
        }
        override fun onLongPress(e: MotionEvent) {
            performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
        }
    }).apply { setOnDoubleTapListener(object : GestureDetector.OnDoubleTapListener {
        override fun onDoubleTap(e: MotionEvent) = false
        override fun onDoubleTapEvent(e: MotionEvent) = false
        override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
            performClick(); return true
        }
    }) }

    // --- ScaleGestureDetector ---
    private val sDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
        private var startScale = 1f
        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
            startScale = scale
            parent?.requestDisallowInterceptTouchEvent(true)
            return true
        }
        override fun onScale(detector: ScaleGestureDetector): Boolean {
            val newScale = (startScale * detector.scaleFactor).coerceIn(minScale, maxScale)
            if (newScale != scale) {
                val focusX = detector.focusX
                val focusY = detector.focusY
                val oldR = r
                val newR = baseR * newScale
                val ratio = newR / oldR
                // 以双指焦点为锚;缩放时同步平移中心,保持视觉稳定
                cx = focusX + (cx - focusX) * ratio
                cy = focusY + (cy - focusY) * ratio
                scale = newScale
                clampInside()
                postInvalidateOnAnimation()
            }
            return true
        }
    })

    init { isClickable = true }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        if (oldw == 0 && oldh == 0) { cx = w / 2f; cy = h / 2f }
    }

    override fun onDraw(c: Canvas) {
        c.drawCircle(cx, cy, r, paint)
        c.drawCircle(cx, cy, r, border)
    }

    override fun onTouchEvent(e: MotionEvent): Boolean {
        // 1) 手势识别
        sDetector.onTouchEvent(e)
        gDetector.onTouchEvent(e)

        // 2) 速度采样
        when (e.actionMasked) {
            MotionEvent.ACTION_DOWN -> {
                tracker = VelocityTracker.obtain().also { it.addMovement(e) }
                dragging = isHit(e.x, e.y)
            }
            MotionEvent.ACTION_MOVE -> tracker?.addMovement(e)
            MotionEvent.ACTION_POINTER_DOWN -> dragging = false // 多指时,不做拖拽
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                tracker?.apply {
                    addMovement(e)
                    computeCurrentVelocity(1000)
                    val vx = xVelocity
                    val vy = yVelocity
                    recycle()
                    if (e.actionMasked == MotionEvent.ACTION_UP) startFling(vx, vy)
                }
                tracker = null
                dragging = false
                parent?.requestDisallowInterceptTouchEvent(false)
            }
        }
        return true
    }

    private fun startFling(vx: Float, vy: Float) {
        if (abs(vx) < minFling && abs(vy) < minFling) {
            springBackIfNeeded(); return
        }
        val minX = r.toInt()
        val maxX = (width - r).toInt()
        val minY = r.toInt()
        val maxY = (height - r).toInt()
        val over = (r * 0.25f).toInt().coerceAtLeast(16) // 允许越界做弹性
        scroller.fling(
            cx.toInt(), cy.toInt(),
            vx.toInt(), vy.toInt(),
            minX, maxX, minY, maxY,
            over, over
        )
        postInvalidateOnAnimation()
    }

    private fun springBackIfNeeded() {
        val minX = r.toInt()
        val maxX = (width - r).toInt()
        val minY = r.toInt()
        val maxY = (height - r).toInt()
        if (scroller.springBack(cx.toInt(), cy.toInt(), minX, maxX, minY, maxY)) {
            postInvalidateOnAnimation()
        }
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            cx = scroller.currX.toFloat()
            cy = scroller.currY.toFloat()
            postInvalidateOnAnimation()
        } else {
            // 动画结束再做一次兜底回弹
            springBackIfNeeded()
        }
    }

    private fun isHit(x: Float, y: Float) = hypot(x - cx, y - cy) <= r
    private fun clampInside() {
        cx = cx.coerceIn(r, width - r)
        cy = cy.coerceIn(r, height - r)
    }
}

使用

<GestureAnimView
    android:layout_width="match_parent"
    android:layout_height="300dp"
    android:background="#11AAAAAA"/>

四、调参与工程化建议

  • 阈值:ViewConfiguration 取

    • scaledTouchSlop(拖拽起效门槛,若用 onScroll 已自动处理)
    • scaledMinimumFlingVelocity / scaledMaximumFlingVelocity(过滤轻微抖动)
  • 回弹强度:fling 的 overX/overY 越大,越“Q 弹”;常用 内容尺寸 * 0.2~0.4。

  • 缩放范围:minScale/maxScale+每次缩放后 clampInside(),避免内容跑出屏幕。

  • 刷新方式:优先 postInvalidateOnAnimation()(API 16+),更平滑、省电。

  • 兼容父容器:开始拖/缩时 requestDisallowInterceptTouchEvent(true);必要时用 NestedScrolling 协议替代粗暴拦截。

  • 资源释放:CANCEL 或手势中断要清理 VelocityTracker、复位 pressed/选中态。

  • 多手势协同:双击缩放与双指缩放共存时,双击里动画到“邻近缩放级”(如 1.0 ↔ 2.0),不要和 onScale 同时改状态。


五、常见坑位清单(对症下药)

  1. 为什么收不到双击/长按? onDown() 没返回 true。
  2. 缩放时画面乱跳? 锚点公式错:应对 (cx,cy) 做关于 (focusX,focusY) 的相似变换。
  3. fling 不回弹/卡在边界? 没给 overX/overY 或 computeScroll() 未持续 postInvalidateOnAnimation()。
  4. 与父容器抢事件(如 NestedScrollView):在 onDown/onScaleBegin 里立刻 requestDisallowInterceptTouchEvent(true)。
  5. 速度过大抖动:对 vx/vy 做阈值过滤,并 coerceIn(-maxFling, maxFling)。
  6. 图片/内容被拉出屏外:缩放/拖拽后统一 clampInside();若是矩阵法,记得用内容边界变换后再裁剪。