如何处理多手指下 Touch 事件中的追踪手指的变化

1,266 阅读10分钟
原文链接: whataa.github.io

手势事件的产生、传递及处理也许我们并不陌生,特别是在自定义一些可交互性的View时,处理手势是不可避免的。而日常中我们接触得最多的是单指产生的手势事件,毕竟除了图片缩放、游戏等情况,谁会没事用两根手指或整块手掌在屏幕上滑来滑去。这就导致了关于如何处理多手指下Touch事件的相关知识的文章很少,本篇笔记便是对多手指Touch变化处理原则的一个归纳与记录。

问题

如果我们仔细观察SDK自带的一些滚动控件的使用效果,便会发现不论是ScrollView还是ListView,一根手指滑动或者手指数量不断变化的情况下滑动,它们都能表现正常;
我们再看看市面上的一些流行应用在这方面的处理,举个例子:例如大家所熟悉的美团Android客户端,其首页的下拉刷新控件便是一个交互性较强的自定义View,当我们单指A向下拖动时,隐藏的“小人儿”逐渐出现,此时我们再放一根手指B到屏幕上并拖动B,似乎控件表现正常,但是此时若放开手指A(保持B),问题便出现了:当前页面从一个位置瞬间移动到了另一个位置!虽然于业务无关痛痒,但是对于体验,却是不可忽视。既然问题已经抛出,接下来我们就通过对手势的分析来确定它是如何产生的并给出解决方案。

手势基础

在此之前我们先了解手势一些最基本的原则:Touch手势的事件除了最常接触的ACTION_DOWNACTION_MOVEACTION_UPACTION_CANCEL外,还包括封装了多指的事件的ACTION_POINTER_DOWNACTION_POINTER_UPAndroid对手势事件产生的处理原则之一就是:

  • 除了第一根手指按下时产生的事件是ACTION_DOWN外,保持第一根手指的同时,其余手指按下时产生的事件都是ACTION_POINTER_DOWN
  • 除了最后一根离开屏幕的手指产生的事件是ACTION_UP外,期间抬起的其余手指产生的事件都是ACTION_POINTER_UP

注意:每一个手势事件都包含了当前所有手指的信息,并非仅有当前事件对应的手指信息!

也就是说,一个完整的Touch手势包括了ACTION_DOWNACTION_MOVEACTION_UP以及在ACTION_DOWNACTION_UP之间可能产生的ACTION_POINTER_DOWNACTION_POINTER_UP所有事件。一个好的建议就是处理手势事件时,利用MotionEvent # getActionMasked()代替MotionEvent # getAction()来得到完整的事件类型。

ID和Index

同时,为了保证手指的唯一性,系统其实为每一根参与的手指分配了一个唯一标识ID,在按下抬起期间始终保持不变;此外,根据ID从小到大排序,每根手指还对应一个索引Index,索引将随着手指数量变化而变化,但所有索引始终保持从0开始。

当手指按下时,系统则通过如下流程图为手指生成正确的ID

image

  • start:手指按下,初始化
  • 操作1:定义a->0, b->0
  • 条件1:索引a处是否存在手指
  • 条件2:索引a处对应的手指的ID是否等于b
  • 操作2:a+1->a, b+1->b
  • 操作3:指定当前按下的手指IDb,并根据所有手指的ID从小到大分配对应的索引
  • end:封装ACTION_DOWNACTION_POINTER_DOWN事件并发送

当抬起手指时,规则就相对简单些:移除对应手指的ID,并根据剩余的所有手指的ID从小到大重新分配对应的索引。

如果还不太明白,我们举个简单的例子来分析这个流程:第一步,我们依次按下手指A、B;第二步,抬起手指A;第三步,按下手指A、C;第四步,依次抬起手指A、B、C。在整个过程中来看看手指ABCID索引Index分别是什么:

第一步:根据按下流程图,AID == 0,Index == 0BID == 1,Index == 1

第二步:抬起A后,在下一个事件到来之前,将移除AID,只剩BID,然后重新分配索引,所以B最终的ID == 1,Index == 0

第三步:按下手指A生成ACTION_POINTERD_DOWN前发现索引0处对应的ID != 0,所以将AID分配为0,然后重新按ID大小分配索引,最终AID == 0,Index == 0BID == 1,Index == 1;继续按下C发现索引0处的ID为0,索引1处的ID为1,索引2处没有手指,所以将CID分配为2,最后再次按ID大小重新分配索引,最终ABC[ID,Index]分别为[0,0], [1,1], [2,2]

第四步:抬起A,剩下BCID 1和2,按ID大小重新分配索引,BC[ID,Index]分别为[0,1], [1,2],抬起B、C依此类推。

那么ID和Index到底有什么用呢,平常似乎并没有使用到。我们可以先看看下面这个疑惑:

可能部分朋友在接触多手指事件处理时心中一直有一个疑问:当屏幕上同时有多根手指在移动,Android是如何决定以哪根手指为准的呢(即:如果是移动视图,当几根手指都在MOVE时,视图到底是跟随哪一根手指MOVE的)?准确地说,“视图移动由谁决定”这问题指的是:在MOVE时,该事件对应的手指到底是哪一根。

其实系统规定 ID最小的手指便是追踪手指。即在MOVE时,event.getX()/getY()对应的手指始终是当前按下 的所有手指中ID最小的。因此,在处理移动事件的时候,要特别注意最小ID发生变化时的情况,手指数量发生变化时,我们如果可以清楚地推算出各手指的ID和Index,最小ID的变化就轻而易举地可以知道。那么接下来我们来尝试分析前面提到的跳动问题的产生原因。

分析原因

既然是移动视图,那么我们就来看看,一个随手指移动的自定义View在手势事件回调方法里最基本的写法:

private float lastX, lastY; 

@Override
public boolean onTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            lastX = ev.getX();
            lastY = ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            ...
            float dx = ev.getX() - lastX;
            float dy = ev.getY() - lastY;
            // then, use dx,dy to do scroll...
            ...
            
            // at last, update them with new values.
            lastX = ev.getX();
            lastY = ev.getY();
            break;
        ... 
    }
}

上面代码中,定义了lastX, lastY用来记录 追踪手指 的最近一次坐标,然后在MOVE时用来和当前最新坐标运算得到手指的移动距离dx, dy,进行移动等操作。在单指情况下,上面的写法没问题。可以走一遍流程验证:按下手指A,由于是第一根手指,所以A的ID和索引分别为0和0,且事件为ACTION_DOWN,在ACTION_DOWN调用getXY()便得到了A手指的坐标;然后移动手指A,产生ACTION_MOVE事件且该事件对应的手指的ID和Index分别为0和0,在此取得该事件产生时手指的坐标,即最新坐标,运算得到移动的距离,进行视图移动,最后更新最近一次坐标lastX'和'lastYACTION_MOVE再次生成到来时,以此类推。

若此时按下手指B,根据前面的流程图分析,D的ID和Index分别为1和1,且事件为ACTION_POINTER_DOWN,继续移动手指(不论A或者B),由于A的ID最小,所以ACTION_MOVE对应的手指依然为A(说明:不论ACTION_MOVE对应的手指是谁,只要手指移动了,都会产生相应的事件并封装到了ACTION_MOVE,只是ev.getXY()可以直接取到该事件对应手指的坐标),移动依然随着A手指移动。

然后此时我们突然松开手指A,那么在ACTION_POINTER_UP后,由于A的ID 0被移除,手指的索引被重新分配,最终B的ID和Index分别为1和0,在继续移动B时,由于此时B的ID最小,所以ACTION_MOVE对应的手指ID发生了变化,即追踪手指由A变为了B,ev.getXY()得到的坐标是B当前最新的坐标,而lastXY缓存的坐标还是手指A在松开前的最终坐标而非手指B的上一个位置的坐标,那么视图就会从上一处位置瞬间移动 A抬起时的位置到B现在位置 这么长的距离,就出现了文章最初的问题。(若松开的是B,由于B的ID并非最小,所以并不影响追踪手指;其实,不论手指有多少,或者如何松开,都是一个道理)

由此便得出了原因:在手指数量变化时,未更新追踪手指的缓存坐标lastX, lastY

解决方案

前面提到,由于是追踪手指发生了变化,使得缓存坐标lastX, lastY记录的依然是上一个追踪手指的最后一次坐标,而导致了瞬间跳动的问题。那么,更新lastX, lastY的时机应该如何挑选,回顾一下,前文提到:

lastXY缓存的坐标还是手指A在松开前的最终坐标而非手指B的上一个位置的坐标”

既然如此,就需要在MOVE前提前修改,手指数量变化有两个时机,先来说ACTION_POINTER_UP,在这个时机之后,下一个事件(例如ACTION_MOVE)发生前,我们必须取到在下一个事件起作用的那个手指的坐标,很显然,只要找到除开松开的那根手指外,剩余手指中ID最小的那个手指,问题就可以解决了;然后我们再来看看ACTION_POINTER_DOWN,同理需要找到包括新加入的手指的所有手指中ID最小的那根,将其坐标更新至lastXY

具体代码演示如下:

private float lastX, lastY;

 @Override
public boolean onTouchEvent(MotionEvent event) {

    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:

            mLastX = event.getX();
            mLastY = event.getY();
            break;

        case MotionEvent.ACTION_POINTER_DOWN:
            // 手指数量发生变化,更新lastXY为追踪手指
            // 根据手指ID生成流程图,在该事件到来时索引0对应的手指一定存在,且ID最小
            mLastX = event.getX(0);
            mLastY = event.getY(0);
            break;

        case MotionEvent.ACTION_POINTER_UP:
            // 定义一个记录当前松开手指Index的变量
            int indexOfUpPointer = event.getActionIndex();
            // 如果松开的手指刚好是追踪手指
            if (indexOfUpPointer == 0) {
                // !!!核心!!!:更新lastXY为接下来将被追踪的手指的当前坐标
                mLastX = event.getX(1);
                mLastY = event.getY(1);
            }

            break;
        case MotionEvent.ACTION_MOVE:
            // do something...
            break;

上述代码的注释已经很清楚了,可以看到,它和原来的代码相比其实并没有增加几行代码,但是那些丑陋的视觉问题却得到了解决。其实,还有许多由多手指引发的不连续的效果的问题,都可以用上述“万金油”方案原理来解决,在此就不一一列举了。当然,更新lastXY的方式也并非此一种,只要理清了原理,便能在实际业务中灵活地处理。

尾语

本文通过一个简单的案例来归纳总结了关于Android多手指Touch变化处理原则的基础知识,对多指缩放、旋转等效果的实现是一个很好的参考。