前言
多点触摸的类型主要分为以下三种:
-
接力型: 后来的手指接管控制权,先前的手指失效。比如列表的滚动,可以通过两根手指的交替来滑动。
-
配合型: 多根手指协同作用,共同得出结果。比如通过所有手指的中心点来移动对象。
-
独立型: 每根手指互不干扰,各自为战。比如绘图软件中,多根手指可以同时作画。
在演示多点触摸之前,先来看看单点触摸的案例。
单点触摸
我们的目标是实现单指移动图片。
先将图片绘制出来:
kotlin
class SingleTouch(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val imageSize = 150.dp
private val bitmap = getBitmap(resources, R.drawable.avatar, imageSize.toInt())
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(bitmap, 0f, 0f, paint)
}
}
运行效果:
实现移动图片的思路也很简单:在按下时,记录初始的触摸点;在后续每次移动中,不断计算出变化量,然后让图片的初始位置加上这个变化量即可。
kotlin
private var imageInitialOffsetX = 0f
private var imageInitialOffsetY = 0f
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val imageOffsetX = imageInitialOffsetX + offsetXChange
val imageOffsetY = imageInitialOffsetY + offsetYChange
canvas.drawBitmap(bitmap, imageOffsetX, imageOffsetY, paint)
}
private var pointDownInitialOffsetX = 0f
private var pointDownInitialOffsetY = 0f
private var offsetXChange = 0f
private var offsetYChange = 0f
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
pointDownInitialOffsetX = event.x
pointDownInitialOffsetY = event.y
}
MotionEvent.ACTION_MOVE -> {
val currentX = event.x
val currentY = event.y
// 变化量
offsetXChange = currentX - pointDownInitialOffsetX
offsetYChange = currentY - pointDownInitialOffsetY
invalidate()
}
}
return true
}
运行效果:
现在还有一个问题:一次移动结束后,如果再次触摸,图片又会回到初始位置。
这个也很好解决,只需在每次手势结束时,将本次的移动距离累加到图片的初始位置上即可。
kotlin
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
// ...
MotionEvent.ACTION_UP -> {
imageInitialOffsetX += offsetXChange
imageInitialOffsetY += offsetYChange
// 重置变化量,为下一次移动做准备
offsetXChange = 0f
offsetYChange = 0f
}
}
return true
}
至此,单指移动图片的功能就完成了。
运行效果:
多点触摸
接下来,我们来完成多点触摸。
核心概念解析
我们先看看不加任何处理时的默认效果:
可以看到,在第二根手指按下时,并没有任何作用。当第一根手指抬起后,第二根手指才起作用。但接着,当第一根手指再次按下后,它却立即抢夺了第二根手指的控制权。
为什么会这样?要理解这一点,需要先了解几个核心概念。
事件序列
触摸事件是一个连续的序列,它针对的是 View 本身,而非某一根手指。例如,ACTION_DOWN
表示 View 上有一根手指按下了,ACTION_MOVE
表示 View 上有一根手指移动了。
一次多点触摸的过程中,事件序列可能为:
kotlin
ACTION_DOWN // 第一根手指落下
ACTION_MOVE
ACTION_POINTER_DOWN // 非第一根手指落下
ACTION_MOVE
ACTION_POINTER_UP // 非最后一根手指抬起
ACTION_MOVE
ACTION_UP // 最后一根手指抬起
每个事件中,都包含了当前所有活动手指的信息。例如:
kotlin
ACTION_DOWN pointer(x,y,index,id)
ACTION_MOVE pointer(x,y,index,id)
ACTION_POINTER_DOWN pointer(x,y,index,id) pointer(x,y,index,id)
ACTION_MOVE pointer(x,y,index,id) pointer(x,y,index,id)
ACTION_POINTER_UP pointer(x,y,index,id) pointer(x,y,index,id)
ACTION_MOVE pointer(x,y,index,id)
ACTION_UP pointer(x,y,index,id)
Pointer ID、Pointer Index
-
Pointer Index
: 指的是手指的索引,从 0 开始。注意:它会动态改变 ,非常不稳定。比如当 0 号手指抬起后,原先为 1 号的手指索引可能会变为 0。所以索引的主要作用就是遍历所有手指。
-
Pointer ID
: 手指的唯一标识,在手指抬起前,它的 ID 始终不会改变。它的作用是追踪特定的一根手指。
坐标获取
getX()
、getY()
方法内部实际上调用的是 getX(0)
、getY(0)
,返回的永远是索引为 0 的那根手指的坐标。
而 getX(pointerIndex)
和 getY(int pointerIndex)
获取的是指定索引的手指坐标。
java
public final float getX() {
return nativeGetAxisValue(mNativePtr, AXIS_X, 0 /*pointerIndex*/, HISTORY_CURRENT);
}
public final float getY() {
return nativeGetAxisValue(mNativePtr, AXIS_Y, 0, HISTORY_CURRENT);
}
现在,我们就能解释默认效果了。因为我们在代码中一直获取的是索引为零的手指坐标,所以控制权是由索引的动态分配来决定的。
当第一根手指抬起后,原来索引是 1 的手指被系统更新为 0 了,所以它会接管控制权;接着,第一根手指重新按下,系统将最新按下的这根手指分配了索引 0,而之前的手指分到索引 1,所以控制权又被新的手指抢走了。
补充:
-
调用
MotionEvent.findPointerIndex(pointerId)
方法可以获取 ID 为pointerId
的手指索引。 -
调用
getActionIndex()
方法,可以获取导致当前事件发生的手指索引。当前事件包括了
ACTION_DOWN
、ACTION_UP
、ACTION_POINTER_DOWN
以及ACTION_POINTER_UP
,其中不包括ACTION_MOVE
事件。因为手指都在不断的、不自觉的移动,获取导致ACTION_MOVE
事件发生的手指索引是无意义的,此时调用该方法,返回值始终为 0。
第一种:接力型
我们的目标是让后按下的手指获取控制权,实现交替滑动。其实思路很清晰:只需追踪最后按下的那一根手指即可。
我们通过 Pointer ID
来追踪它,在 ACTION_MOVE
中,计算它的移动距离。
kotlin
// 记录当前所有手指的 ID,列表的末尾就是最后按下的手指
private val trackingPointerIds = mutableListOf<Int>()
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
// 初始触摸点
pointDownInitialOffsetX = event.x
pointDownInitialOffsetY = event.y
// 添加第一个手指的 ID
trackingPointerIds.add(event.getPointerId(0))
}
MotionEvent.ACTION_POINTER_DOWN -> {
val actionIndex = event.actionIndex
// 接力发生
// 将旧手指的移动距离累加到图片的初始偏移中
imageInitialOffsetX += offsetXChange
imageInitialOffsetY += offsetYChange
// 重置变化量
offsetXChange = 0f
offsetYChange = 0f
// 更新触摸点为新按下的手指
pointDownInitialOffsetX = event.getX(actionIndex)
pointDownInitialOffsetY = event.getY(actionIndex)
// 将新手指的 ID 加入追踪列表
trackingPointerIds.add(event.getPointerId(actionIndex))
}
MotionEvent.ACTION_MOVE -> {
// 获取最新手指的 ID
val pointerId = trackingPointerIds.last()
val index = event.findPointerIndex(pointerId)
val currentX = event.getX(index)
val currentY = event.getY(index)
offsetXChange = currentX - pointDownInitialOffsetX
offsetYChange = currentY - pointDownInitialOffsetY
invalidate()
}
MotionEvent.ACTION_POINTER_UP -> {
val actionIndex = event.actionIndex
val pointerId = event.getPointerId(actionIndex)
// 如果抬起的是最新手指
if (pointerId == trackingPointerIds.last()) {
// 固化偏移
imageInitialOffsetX += offsetXChange
imageInitialOffsetY += offsetYChange
// 重置变化量
offsetXChange = 0f
offsetYChange = 0f
// 从追踪列表移除
trackingPointerIds.remove(pointerId)
// 使用新的最后一根手指,更新触摸点
val newLastPointerId = trackingPointerIds.last()
val newLastIndex = event.findPointerIndex(newLastPointerId)
pointDownInitialOffsetX = event.getX(newLastIndex)
pointDownInitialOffsetY = event.getY(newLastIndex)
} else {
// 如果不是,直接移除
trackingPointerIds.remove(pointerId)
}
}
MotionEvent.ACTION_UP -> {
imageInitialOffsetX += offsetXChange
imageInitialOffsetY += offsetYChange
offsetXChange = 0f
offsetYChange = 0f
// 所有手指都已抬起,清空追踪列表
trackingPointerIds.clear()
}
}
return true
}
关键在于,每当最新的手指可能发生变化时,都要及时更新图片的初始位置和手指按下的初始位置,并重置变化量,这样才能避免画面跳动。
运行效果:
第二种:配合型
目标是所有手指同时起作用,根据它们的中心点来移动图片。
这时,我们可以计算所有手指坐标的平均值,将这个计算得出的中心点作为后续计算的基础,逻辑和单点触摸非常类似。
kotlin
// MultiTouchView.kt
// ...
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
// 当手指数量变化时,固化之前的偏移,并以新的中心点作为起点
updateImageOffset()
updatePointDownOffset(event)
}
MotionEvent.ACTION_MOVE -> {
val focusPoint = getFocusedPoint(event)
offsetXChange = focusPoint.x - pointDownInitialOffsetX
offsetYChange = focusPoint.y - pointDownInitialOffsetY
invalidate()
}
MotionEvent.ACTION_POINTER_UP -> {
// 手指抬起,数量变化,同样需要固化并重置起点
updateImageOffset()
updatePointDownOffset(event)
}
MotionEvent.ACTION_UP -> {
updateImageOffset()
}
}
return true
}
/**
* 固化偏移
*/
private fun updateImageOffset() {
imageInitialOffsetX += offsetXChange
imageInitialOffsetY += offsetYChange
offsetXChange = 0f
offsetYChange = 0f
}
/**
* 更新触摸点按下的初始偏移
*/
private fun updatePointDownOffset(event: MotionEvent) {
val focusPoint = getFocusedPoint(event)
pointDownInitialOffsetX = focusPoint.x
pointDownInitialOffsetY = focusPoint.y
}
private fun getFocusedPoint(event: MotionEvent): PointF {
var sumX = 0f
var sumY = 0f
val pointCount = event.pointerCount
for (index in 0 until event.pointerCount) {
sumX += event.getX(index)
sumY += event.getY(index)
}
// pointCount 为 0 时避免除零错误
return if (pointCount > 0) {
PointF(sumX / pointCount, sumY / pointCount)
} else {
// 如果没有点了,返回默认位置
PointF(0f, 0f)
}
}
不过,这个实现有个问题:当有手指抬起后,焦点中心会瞬间改变,导致画面发生跳动。
我们可以在计算焦点时,增加一个判断,忽略掉那根即将抬起的手指。
kotlin
private fun getFocusedPoint(event: MotionEvent): PointF {
var sumX = 0f
var sumY = 0f
var pointCount = event.pointerCount
// 当有手指抬起时,这次事件中依然包含它
// 我们需要手动将它排除,以防画面跳动
val isPointerUp = event.actionMasked == MotionEvent.ACTION_POINTER_UP
if (isPointerUp) {
pointCount--
}
for (index in 0 until event.pointerCount) {
// 如果是抬起事件,则跳过触发该事件的手指
if (isPointerUp && index == event.actionIndex) {
continue
}
sumX += event.getX(index)
sumY += event.getY(index)
}
return if (pointCount > 0) {
PointF(sumX / pointCount, sumY / pointCount)
} else {
PointF(0f, 0f)
}
}
核心在于,任何手指数量的变化,都要提交之前的位移,并使用新的中心点作为下一次移动的起点。
运行效果:
第三种:独立型
以多指绘图为例。
我们先来完成单指画线功能,思路并不难:以手指按下为起点,每次移动就绘制一条线,直到手指抬起。
kotlin
// MultiTouchDrawView.kt
class MultiTouchDrawView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4.dp
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private var path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawPath(path, paint)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
path.moveTo(event.x, event.y)
invalidate()
}
MotionEvent.ACTION_MOVE -> {
path.lineTo(event.x, event.y)
invalidate()
}
MotionEvent.ACTION_UP -> {
invalidate()
}
}
return true
}
}
运行效果:
需要实现每根手指独立作用,互不干扰。
我们可以为每一根手指创建一个专属的 Path
对象。使用 SparseArray<Path>
来存储 ID
到 Path
的映射关系。
kotlin
class MultiTouchDrawView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 4.dp
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
private val paths = SparseArray<Path>()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
for (i in 0 until paths.size) {
val path = paths.valueAt(i)
canvas.drawPath(path, paint)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val actionIndex = event.actionIndex
val pointerId = event.getPointerId(actionIndex)
val path = Path()
path.moveTo(event.getX(actionIndex), event.getY(actionIndex))
paths.append(pointerId, path)
invalidate()
}
MotionEvent.ACTION_MOVE -> {
// MOVE 事件没有 actionIndex,我们需要遍历所有手指,更新它们各自的 Path
for (index in 0 until event.pointerCount) {
val pointerId = event.getPointerId(index)
val path = paths.get(pointerId)
path?.lineTo(event.getX(index), event.getY(index))
}
invalidate()
}
MotionEvent.ACTION_POINTER_UP -> {
val pointerId = event.getPointerId(event.actionIndex)
paths.remove(pointerId)
invalidate()
}
MotionEvent.ACTION_UP -> {
// 最后一根手指抬起,清空画布
paths.clear()
invalidate()
}
}
return true
}
}
关键在于 ACTION_MOVE
中,因为它是针对整个 View 的,我们无法知道哪根手指移动了(都在微动),所以我们直接更新了当前所有手指对应的路径。
总结
可以看到,无论是哪种类型的多点触摸,其底层都离不开对触摸事件核心机制的理解。
多点触摸的核心原则:
-
使用 index 在单次事件遍历所有手指,使用 id 追踪某根手指。
-
在
ACTION_POINTER_DOWN
、ACTION_POINTER_UP
事件中,需要正确更新状态,这样可以防止画面跳动。 -
在多点触摸中,应该始终使用带参的
getX
、`getY 方法。