深入解析 Android 多点触摸:从原理到实战

前言

多点触摸的类型主要分为以下三种:

  • 接力型: 后来的手指接管控制权,先前的手指失效。比如列表的滚动,可以通过两根手指的交替来滑动。

  • 配合型: 多根手指协同作用,共同得出结果。比如通过所有手指的中心点来移动对象。

  • 独立型: 每根手指互不干扰,各自为战。比如绘图软件中,多根手指可以同时作画。

在演示多点触摸之前,先来看看单点触摸的案例。

单点触摸

我们的目标是实现单指移动图片。

先将图片绘制出来:

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,所以控制权又被新的手指抢走了。

补充:

  1. 调用 MotionEvent.findPointerIndex(pointerId) 方法可以获取 ID 为 pointerId 的手指索引。

  2. 调用 getActionIndex() 方法,可以获取导致当前事件发生的手指索引。

    当前事件包括了 ACTION_DOWNACTION_UPACTION_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> 来存储 IDPath 的映射关系。

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 的,我们无法知道哪根手指移动了(都在微动),所以我们直接更新了当前所有手指对应的路径。

总结

可以看到,无论是哪种类型的多点触摸,其底层都离不开对触摸事件核心机制的理解。

多点触摸的核心原则:

  1. 使用 index 在单次事件遍历所有手指,使用 id 追踪某根手指。

  2. ACTION_POINTER_DOWNACTION_POINTER_UP 事件中,需要正确更新状态,这样可以防止画面跳动。

  3. 在多点触摸中,应该始终使用带参的 getX、`getY 方法。

相关推荐
曾经的三心草4 小时前
Python2-工具安装使用-anaconda-jupyter-PyCharm-Matplotlib
android·java·服务器
Jerry5 小时前
Compose 设置文字样式
android
飞猿_SIR5 小时前
android定制系统完全解除应用安装限制
android
索迪迈科技6 小时前
影视APP源码 SK影视 安卓+苹果双端APP 反编译详细视频教程+源码
android·影视app源码·sk影视
孔丘闻言6 小时前
python调用mysql
android·python·mysql
萧雾宇8 小时前
Android Compose打造仿现实逼真的烟花特效
android·flutter·kotlin
翻滚丷大头鱼9 小时前
android 性能优化—ANR
android·性能优化
翻滚丷大头鱼9 小时前
android 性能优化—内存泄漏,内存溢出OOM
android·性能优化
拜无忧9 小时前
【教程】flutter常用知识点总结-针对小白
android·flutter·android studio