Android开发时的多点触控是如何实现的?

博文小编

2021-01-13


对于Android自定义控件开发,多点触控是一个必须要懂的知识点。因为在正常的情况下操作正常的控件,使用多指操作时,基本上都会出现问题。当需要对多指操作进行兼容时,就需要这方面的知识了。

本文选自《Android自定义控件高级进阶与精彩实例》一书,带你了解多点触控的基本知识。


—— 正文 ——
假如,我们做了这么一个功能,图像跟随手指移动。

在单指操作下,图像的移动非常流畅、正确,而如果我们使用两根手指的话,就会出现下面这种情况。

从效果图可以看出,在第2根手指放下,而第1根手指抬起时,图像会出现跳跃,直接从第1根手指的位置移动到了第2根手指的位置,这明显是不对的。这只是一个简单的例子,一般使用单指操作的控件改到多指操作的时候,都会出现问题。

这便是本文讲解多点触控的初衷。既然多点触控会造成这么多问题,那么下面就来详细了解它吧。


单点触控与多点触控

1

单点触控
单点触控与多点触控是相对的,单点触控的意思是,我们只考虑一根手指的情况,而且仅处理一根手指的触摸事件,而多点触控是处理多根手指的触摸事件。

一般我们处理MotionEvent事件,通过MotionEvent.getAction来获取事件类型,这就是单点触控。在单点触控中,会涉及对下面几个消息的处理。


除了消息外,我们也经常用下面这几个函数来获取手指的位置等信息,这些函数都没有参数,也都只有在单点触控时才能使用。


对于这几个函数的使用方法,这里就不再赘述了。可以看到,我们平常所处理的MotionEvent事件,以及常用的MotionEvent函数都只是针对单点触控的,那么哪些才是多点触控的事件和函数呢?

2

多点触控
首先,多点触控的消息类型只能通过getActionMasked来获取。因此,判断当前代码处理的是单点触控还是多点触控,单从获取消息类型的函数就可以看出。

说明:单点触控是通过getAction来获取当前事件类型的,而多点触控是通过getActionMasked来获取的。
多点触控涉及的消息类型与单点触控的不一样,它的消息类型如下。


比如以下图中的手指按下顺序,我们来看看其中的事件触发顺序。


在效果图中,先后有3根手指按下,按下顺序是1、2、3,抬起顺序是1、3、2,而事件触发顺序如下表。


这里需要注意,
第1根手指按下时,收到的消息是ACTION_DOWN;
随后的手指再按下时,收到的是ACTION_POINTER_DOWN;
当有手指抬起时,收到的是ACTION_POINTER_UP;
当最后一根手指抬起时,收到的是ACTION_UP。

对多点触控消息进行处理的代码如下:

 1String TAG = "qijian";
 2@Override
 3public boolean onTouchEvent(MotionEvent event) {
 4    switch (event.getActionMasked()) {
 5    case MotionEvent.ACTION_DOWN:
 6        Log.e(TAG,"第1根手指按下");
 7        break;
 8    case MotionEvent.ACTION_UP:
 9        Log.e(TAG,"最后一根手指抬起");
10        break;
11    case MotionEvent.ACTION_POINTER_DOWN:
12        Log.e(TAG,"又一根手指按下");
13        break;
14    case MotionEvent.ACTION_POINTER_UP:
15        Log.e(TAG,"又一根手指抬起");
16        break;
17    }
18    return true;
19}
20...
21    }

这里仅列出了手指按下和手指抬起所触发的消息类型,而在手指移动时,无论是单点触控还是多点触控,所触发的消息都是MotionEvent.ACTION_MOVE。
在多点触控时,我们可以通过代码来获取当前移动的是哪根手指。

多点触控

1

识别按下的手指
上面讲解了在什么情况下会触发什么消息,但我们怎么来识别当前按下的是哪根手指呢?

在MotionEvent中有一个Pointer的概念:

一个Pointer就代表一个触摸点,每个Pointer都有自己的消息类型,也有自己的X坐标值。一个MotionEvent对象中可能会存储多个Pointer的相关信息,每个Pointer都有自己的PointerIndex和PointerId。在多点触控中,就是用PointerIndex和PointerId来标识用户手指的。

PointerIndex表示当前手指的索引,PointerId是手指按下时分配的唯一id,用来标识这根手指。

每根手指从按下、移动到离开屏幕,PointerId是不变的,而PointerIndex则不是固定的。

通过下面这个例子,我们来了解一下PointerIndex与PointerId的区别。


可见同一根手指的id是不变的,而PointerIndex是会变化的,但总是以0、1或者0、1、2这样的形式出现,而不可能出现0、2这样间隔了一个数或者1、2这种没有0索引值的形式。

针对PointerIndex与PointerId,在MotionEvent类中经常使用下面这几个函数。

public final int getActionIndex:
用于获取当前活动手指的PointerIndex值。

public final int getPointerId(int pointerIndex):
用于根据PointerIndex值获取手指的PointerId,其中pointerIndex表示手指的PointerIndex值。

public final int getPointerCount:
用于获取用户按下的手指个数,一般我们用它来遍历屏幕上的所有手指,遍历手指的代码如下:

1for (int i = 0; i < event.getPointerCount(); i++) {
2    int pointerId = event.getPointerId(i);
3}

前面讲过,PointerIndex是从0开始的,表示当前所有手指的索引,值从0到getPointerCount() − 1,不会出现不连续的数。因此,我们通过event.getPointerCount可以得到当前屏幕上的手指个数。然后从0开始遍历PointerIndex,同时我们还能通过int pointerId = event.getPointerId(i)来得到每根手指PointerIndex所对应的PointerId。
public final int findPointerIndex(int pointerId):
用于根据PointerId反向找到手指的PointerIndex值。

由此,我们就知道了PointerIndex与PointerId的关系,以及它们相互之间的换算方法。下面再来看看通过PointerIndex和PointerId能得到什么。

2

获取手指位置信息
通过PointerIndex与PointerId,可以使用以下函数获得手指的位置信息。

public final float getX(int pointerIndex):
根据PointerIndex得到对应手指的X坐标值,该函数的意义与单点触控里的getX函数相同。

public final float getY(int pointerIndex):
同样地,根据PointerIndex得到对应手指的Y坐标值,该函数的意义与单点触控里的getY函数相同。

实例:追踪第2根手指

现在,我们将通过一个实例来学习上面讲到的函数。

这里实现的效果是:当用户按下第2根手指时,就开始追踪这根手指,无论其他手指是否抬起,只要这根手指没有抬起,就一直显示这根手指的位置,如下图。


从效果图可以看出,先后总共按下了3根手指,分别在左(第1根手指)、中(第2根手指)、右(第3根手指)。

抬起手指时,先抬起左侧第1根手指,然后抬起右侧第3根手指。可以看到,第2根手指的触摸点,我们使用白色圆圈显示,无论第3根手指是否按下,还是其他手指是否抬起,白色圆圈总是跟着第2根手指的移动来显示。这就实现了跟踪第2根手指轨迹的效果。

下面我们来看看这个效果是怎么实现的吧。

1

自定义View并初始化
布局很简单,就是一个全屏View,为了在View上画圆圈,我们必须自定义View,其中的初始化代码如下:

 1public class MultiTouchView extends View {
 2    // 用于判断第2根手指是否存在
 3    private boolean haveSecondPoint = false;
 4    // 记录第2根手指的位置
 5    private PointF point = new PointF(0, 0);
 6    private Paint mDefaultPaint = new Paint();
 7
 8    public MultiTouchView(Context context) {
 9        super(context);
10        init();
11    }
12
13    public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
14        super(context, attrs);
15        init();
16    }
17
18    public MultiTouchView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
19        super(context, attrs, defStyleAttr);
20        init();
21    }
22
23    private void init() {
24        mDefaultPaint.setColor(Color.WHITE);
25        mDefaultPaint.setAntiAlias(true);
26        mDefaultPaint.setTextAlign(Paint.Align.CENTER);
27        mDefaultPaint.setTextSize(30);
28    }
29}

这样我们就自定义了一个View,很明显它内部不会再包裹其他的View控件,所以继承自View类即可。
我们定义了3个变量,其中:

haveSecondPoint用于判断第2根手指是否按下。
point用于记录第2根手指的位置。
mDefaultPaint是画笔变量,用于画第2根手指位置处的白色圆圈。

2

onTouchEvent
然后,在用户按下手指时,需要加以判断,当前是第几根手指,然后获取第2根手指的位置,下面列出完整代码:

 1public boolean onTouchEvent(MotionEvent event) {
 2    int index = event.getActionIndex();
 3
 4    switch (event.getActionMasked()) {
 5    case MotionEvent.ACTION_POINTER_DOWN:
 6        if (event.getPointerId(index) == 1) {
 7            haveSecondPoint = true;
 8            point.set(event.getX(), event.getY());
 9        }
10        break;
11    case MotionEvent.ACTION_MOVE:
12        try {
13            if (haveSecondPoint) {
14                int pointerIndex = event.findPointerIndex(1);
15                point.set(event.getX(pointerIndex), event.getY(pointerIndex));
16            }
17        } catch (Exception e) {
18            haveSecondPoint = false;
19        }
20        break;
21    case MotionEvent.ACTION_POINTER_UP:
22        if (event.getPointerId(index) == 1) {
23            haveSecondPoint = false;
24        }
25        break;
26    case MotionEvent.ACTION_UP:
27        haveSecondPoint = false;
28        break;
29    }
30
31    invalidate();
32    return true;
33}

获取当前活动手指的PointerIndex值:

1int index = event.getActionIndex();

我们知道,当第1根手指按下的时候触发的是ACTION_DOWN消息,随后的手指按下的时候触发的都是ACTION_POINTER_DOWN消息。因为我们要跟踪第2根手指,所以这里只需要识别ACTION_POINTER_DOWN消息即可:

1case MotionEvent.ACTION_POINTER_DOWN:
2    if (event.getPointerId(index) == 1) {
3        haveSecondPoint = true;
4        point.set(event.getX(), event.getY());
5    }
6    break;

我们也知道PointerIndex是变化的,而PointerId是不变的,PointerId根据手指按下的顺序从0到1逐渐增加。因此,第2根手指的PointerId就是1。当(event.getPointerId(index) == 1时,就表示当前按下的是第2根手指,将haveSecondPoint设为true,并将得到的第2根手指的位置设置到point中。

到这里,大家可能会产生疑问,上面提到的多点触控获取手指位置都用的是event.getX(pointerIndex),而这里怎么直接用event.getX了呢?其实这里使用event.getX (pointerIndex)也是可以的,大家可以先记下这个问题,后面我们再详细讲解。

当手指移动时,会触发ACTION_MOVE消息:

 1case MotionEvent.ACTION_MOVE:
 2    try {
 3        if (haveSecondPoint) {
 4            int pointerIndex = event.findPointerIndex(1);
 5            point.set(event.getX(pointerIndex), event.getY(pointerIndex));
 6        }
 7    } catch (Exception e) {
 8        haveSecondPoint = false;
 9    }
10    break;

需要注意,因为这里使用event.findPointerIndex(1)来强制获取PointerId为1的手指PointerIndex,在异常情况下可能出现越界,所以使用try…catch…来进行保护。

在这里,我们使用event.getX(pointerIndex)来获取指定手指的位置信息。同样地,这个问题也放在后面讲解。

当手指抬起时,会触发ACTION_POINTER_UP消息:

1case MotionEvent.ACTION_POINTER_UP:
2    if (event.getPointerId(index) == 1) {
3        haveSecondPoint = false;
4    }
5    break;

同样地,使用event.getPointerId(index)来获取当前抬起手指的PointerId,如果是1,那就说明是第2根手指抬起了,这时就把haveSecondPoint设为false。
当全部手指抬起时,会触发ACTION_UP消息:

1case MotionEvent.ACTION_UP:
2    haveSecondPoint = false;
3    break;

在最后一根手指抬起时,把haveSecondPoint设为false,白色圆圈从屏幕上消失。
最后,调用invalidate();来重绘界面。

3

onDraw
在重绘界面时,主要是在point中存储的第2根手指的位置处画一个白色圆圈:

 1protected void onDraw(Canvas canvas) {
 2
 3    canvas.drawColor(Color.GREEN);
 4    if (haveSecondPoint) {
 5        canvas.drawCircle(point.x, point.y, 50, mDefaultPaint);
 6    }
 7
 8    canvas.save();
 9    canvas.translate(getMeasuredWidth() / 2, getMeasuredHeight() / 2);
10    canvas.drawText("追踪第2个按下手指的位置", 0, 0, mDefaultPaint);
11    canvas.restore();
12}

首先,为整个屏幕绘一层绿色,把上一屏的内容清掉:

1canvas.drawColor(Color.GREEN);

然后,如果第2根手指按下了,则在它的位置处画一个圆圈:

1if (haveSecondPoint) {
2    canvas.drawCircle(point.x, point.y, 50, mDefaultPaint);
3}

最后,在布局的中间位置写上提示文字:

1canvas.save();
2canvas.translate(getMeasuredWidth() / 2, getMeasuredHeight() / 2);
3canvas.drawText("追踪第2个按下手指的位置", 0, 0, mDefaultPaint);
4canvas.restore();

有关Canvas的操作及写字的操作,在《Android自定义控件开发入门与实战》一书中有详细章节讲述,这里就不再赘述了。

在写好控件以后,直接利用XML引入布局即可,这里不再展示,效果就是我们想要的样子。

关于作者
启舰
本名张恩伟,Android研发专家、CSDN博客专家、CSDN博客之星,《Android自定义控件入门与实战》《Android自定义控件高级进阶与精彩实例》作者,电子工业出版社博文视点优秀作者,曾就职于阿里巴巴,现就职于vivo。

图书推荐

▊《Android自定义控件高级进阶与精彩实例》
启舰 著
专注于介绍Android自定义控件进阶知识
通过精彩的案例对各种绘制、动画技术进行了糅合讲解
本书主要内容有3D特效的实现、高级矩阵知识、消息处理机制、派生类型的选择方法、多点触控及辅助类、RecyclerView的使用方法及3D卡片的实现、动画框架Lottie的讲解与实战等。
读者可以通过本书从宏观层面、源码层面对Android自定义控件建立完整的认识。

读者评论

相关博文

  • 手把手教你实现Android开发中的3D卡片翻转效果!

    手把手教你实现Android开发中的3D卡片翻转效果!

    博文小编 2021-06-09

    以下内容节选自《Android自定义控件高级进阶与精彩实例》一书! 《Android自定义控件高级进阶与精彩实例》一书中有一个使用Camera类(书中有对该类的详细讲解)实现3D卡片翻转效果的例子(效果如下所示)。 项目地址:...

    博文小编 2021-06-09
    2956 0 0 0