一、触摸/动画总流程
-
事件进入 View:onTouchEvent(ev)
-
手势识别:把 ev 同时喂给
- gestureDetector.onTouchEvent(ev)(单击/长按/双击/滚动)
- scaleDetector.onTouchEvent(ev)(双指缩放)
-
速度采样:velocityTracker.addMovement(ev);在 UP 调 computeCurrentVelocity(1000) 拿 px/s
-
启动动画:用 OverScroller.fling(...)(或 springBack(...))
-
驱动绘制:computeScroll() 中 if (scroller.computeScrollOffset()) → 更新位置/缩放 → postInvalidateOnAnimation()
-
边界/回弹:给 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 同时改状态。
五、常见坑位清单(对症下药)
- 为什么收不到双击/长按? onDown() 没返回 true。
- 缩放时画面乱跳? 锚点公式错:应对 (cx,cy) 做关于 (focusX,focusY) 的相似变换。
- fling 不回弹/卡在边界? 没给 overX/overY 或 computeScroll() 未持续 postInvalidateOnAnimation()。
- 与父容器抢事件(如 NestedScrollView):在 onDown/onScaleBegin 里立刻 requestDisallowInterceptTouchEvent(true)。
- 速度过大抖动:对 vx/vy 做阈值过滤,并 coerceIn(-maxFling, maxFling)。
- 图片/内容被拉出屏外:缩放/拖拽后统一 clampInside();若是矩阵法,记得用内容边界变换后再裁剪。