Android自定义View系列:多点触控原理

事件拦截机制

阅读下面的多点触控原理知识,需要了解一定的事件拦截机制原理,可以阅读文章:Android自定义View系列:事件拦截机制原理

getAction() 和 getActionMasked() 的区别

现在网上还是有很多博客在 onTouchEvent() 处理触摸反馈判断时使用的 MotionEvent.getAction(),那么 MotionEvent.getAction()MotionEvent.getActionMasked() 有什么区别呢?为什么推荐我们使用 MotionEvent.getActionMasked()

MotionEvent.getAction() 是在早期 Android 版本就已经存在,在只有单点触控的时候 只包含事件信息,比如 MotionEvent.ACTION_DOWN 、MotionEvent.ACTION_UP、MotionEvent.ACTION_MOVE。

但在多点触控时它就多了一个信息:还需要知道按下的时候你是第一个手指还是非第一个手指,抬起的时候是最后一个手指抬起了还是非最后一个手指抬起了

因为多点触控的处理逻辑和单点触控不同,比如 MotionEvent.ACTION_POINTER_DOWN、MotionEvent.ACTION_POINTER_UP,所以就要区分两种分别处理,Android 因此在 API 8 提供了 MotionEvent.getActionMasked()MotionEvent.getActionIndex()

那么 MotionEvent.getAction() 就要将两个信息压缩到32位的 int 类型里面,而 MotionEvent.getActionMasked() 是拆分了信息只有事件信息。

因此在现在的单点触控情况下使用 MotionEvent.getAction() 是可以正常使用的,但在多点触控情况下,它没有将多点触控的信息拆分全部压在32位 int 类型里面,所以处理的信息就会是错误的。

实际开发中,我们推荐使用 MotionEvent.getActionMasked()MotionEvent.getActionIndex() 来分别判断回调的事件信息和获取哪个手指处理事件。

单点触控事件序列:

ACTION_DOWN P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_MOVE P(x, y)
ACTION_UP P(x, y)

多点触控事件序列:

ACTION_DOWN:第一个手指按下(之前没有任何手指触摸到View)
ACTION_MOVE:有手指发生移动
ACTION_MOVE
ACTION_POINTER_DOWN:额外手指按下(按下之前已经有别的手指触摸到View)
ACTION_MOVE
ACTION_MOVE
ACTION_POINTER_UP:有手指抬起,但不是最后一个(抬起之后,仍然还有别的手指在触摸着View)
ACTION_MOVE
ACTION_UP:最后一个手指抬起(抬起之后没有任何手指触摸到View,这个手指未必是ACTION_DOWN的那个手指)

可以发现相比单点触控,处理多点触控时多了 MotionEvent.ACTION_POINTER_DOWN 和 MotionEvent.ACTION_POINTER_UP。

多点触控事件序列分析

在说明多点触控的三种使用场景之前,有必要理解多点触控事件序列在处理过程中与单点触控的不同。

index 和 id

上面的多点触控事件序列我们再将详细信息追加上:

ACTION_DOWN P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_POINTER_DOWN P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id) P(x, y, index, id)
ACTION_POINTER_UP P(x, y, index, id) P(x, y, index, id)
ACTION_MOVE P(x, y, index, id)
ACTION_UP P(x, y, index, id)

pointer 多了一些数据:index 和 id。

  • index:当事件序列开始 MotionEvent.ACTION_DOWN 的时候,该手指默认会分配一个索引 index=0,表示在多点触控时该手指的索引,通过该索引我们可以在 onTouchEvent() 时通过 MotionEvent.getX(index)MotionEvent.getY(index) 获取手指在 View 的坐标

需要特别注意的是:index 它并不是固定的,它根据事件序列的改变而改变,它总是有序的

比如:

ACTION_DOWN P(x, y, 0, 0)
ACTION_MOVE P(x, y, 0, 0)
ACTION_MOVE P(x, y, 0, 0)
ACTION_POINTER_DOWN P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_POINTER_UP P(x, y, 0, 0) P(x, y, 1, 1)
ACTION_MOVE P(x, y, 0, 1)
ACTION_UP P(x, y, 0, 1)

上面的事件序列假设有两个手指,触发 MotionEvent.ACTION_DOWN 手指为 index=0,id=0。

在事件处理过程中 MotionEvent.ACTION_POINTER_DOWN 被回调,说明另一个手指加入事件序列,手指为 index=1, id=1。

在事件处理过程中 MotionEvent.ACTION_POINTER_UP 被回调,有手指松开,松开的手指是最开始触摸 View 的第一个手指 index=0, id=0,所以在最后是手指 id=1 的手指继续事件序列,并且它的 index 变为 0。

我们可以看下 getX()getY() 的源码:

public final float getX() {
    return nativeGetAxisValue(mNativePtr, AXIS_X, 0, HISTORY_CURRENT);
}
public final float getY() {
     return nativeGetAxisValue(mNativePtr, AXIS_Y, 0, HISTORY_CURRENT);
 }

getX()getY() 的源码其中 pointerIndex(第三个参数) 的值默认为 0,就是上面举例的index。

所以在多点触控中,getX()getY() 是不可用的,它已经将 pointerIndex 写死值为 0,所以无法准确获取到需要手指的坐标位置。需要更改为使用 getX(pointerIndex)getY(pointerIndex) 来获取:

public final float getX(int pointerIndex) {
    return nativeGetAxisValue(mNativePtr, AXIS_X, pointerIndex, HISTORY_CURRENT);
}
public final float getY(int pointerIndex) {
    return nativeGetAxisValue(mNativePtr, AXIS_Y, pointerIndex, HISTORY_CURRENT);
}

既然 index 是随时都在变化的,那么你可能会疑问:那在多点触控的时候,对应手指的索引要怎么拿?我应该怎么才能拿到触摸 View 手指的坐标?

答:id。

上面事件序列的 pointer 还有 id,id 在整个事件序列中它都是固定不变的,通过它我们就可以获取手指当前的 index,继而通过 index 再调用 getX(index)getY(index) 获取到坐标点。

Android 给我们提供了几个 API 在多点触控场景下使用:

  • MotionEvent.getX(pointerIndex):通过 pointerIndex 获取触摸 View 的某个手指的横坐标

  • MotionEvent.getY(pointerIndex):通过 pointerIndex 获取触摸 View 的某个手指的纵坐标

  • MotionEvent.getPointerId(pointerIndex):通过 pointerIndex 获取触摸 View 的某个手指 id

  • MotionEvent.findPointerIndex(id):通过 id 获取触摸View的某个手指当前的 pointerIndex 索引

  • MotionEvent.getActionIndex():在 MotionEvent.ACTION_POINTER_DOWN 获取非第一个手指按下的手指索引,在 MotionEvent.ACTION_POINTER_UP 获取非最后一个手指按下的索引。该 API 在多点触控只适用于上面两种常见回调使用

getActionIndex()

getActionIndex() 这个 API 比较特殊,它在多点触控的场景只适用于 MotionEvent.ACTION_POINTER_DOWN 和 MotionEvent.ACTION_POINTER_UP 事件回调时使用。那为什么不能在 MotionEvent.ACTION_MOVE 获取调用?

当多个手指触摸的时候,其实多个手指是在同时移动的(手指的轻微移动都会回调 MotionEvent.ACTION_MOVE)。

我们上面说到 pointer 的索引 index 是会根据事件序列的改变而改变的,也就是说导致 MotionEvent.ACTION_MOVE 的手指索引是不断切换的。

所以在 MotionEvent.ACTION_MOVE 并不方便获取正在导致那个事件的手指,也是没有意义的,在 MotionEvent.ACTION_MOVE 调用 MotionEvent.getActionIndex() 总是返回 0,该值没有任何意义不代表哪个手指的索引

多点触控的三种使用场景

接力型

接力型:同一时刻只有一个 pointer 起作用,即最新的 pointer。典型:ListView、RecyclerView。

接力型的场景说明简单来说就是:假设有两个手指,一个手指在触摸移动 View 的时候,另一个手指介入此次事件序列,然后其中一个手指松手,另一个手指接管了事件序列直到结束。

实现方式:在 MotionEvent.ACTION_POINTER_DOWN 和 MotionEvent.ACTION_POINTER_UP 时记录下最新的 pointer,在之后的 MotionEvent.ACTION_MOVE 事件中使用这个 pointer 来判断位置。

demo:多点触控接力移动图片

public class MultiTouchView extends View {
	private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Bitmap bitmap;
    private float downX;
    private float downY;
    private float originalOffsetX;
    private float originalOffsetY;
    private float offsetX;
    private float offsetY;
    private int trackingPointerId;

    public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
       super(context, attrs);
	   bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
   }

	 // 多指接力型(两个手指滑动时,一个手指放开另外一个手指接手滑动)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN: // 第一根手指按下回调
                trackingPointerId = event.getPointerId(0); // 第一根手指index为0
                updateOffset(event, 0);
                break;
            case MotionEvent.ACTION_MOVE:
                int index = event.findPointerIndex(trackingPointerId); // 根据id获取索引
                offsetX = event.getX(index) - downX + originalOffsetX;
                offsetY = event.getY(index) - downY + originalOffsetY;
                invalidate();
                break;
            case MotionEvent.ACTION_POINTER_DOWN: // 非第一根手指按下回调
                // 有新手指加入事件序列,更新跟踪的手指id
                int actionIndex = event.getActionIndex();
                trackingPointerId = event.getPointerId(actionIndex);

                // 更新为新手指的坐标,让新手指接管事件
                updateOffset(event, actionIndex);
                break;
            case MotionEvent.ACTION_POINTER_UP: // 非第一个手指松手回调
                actionIndex = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex);
                // 松手的手指是当前正在跟踪的手指
                if (pointerId == trackingPointerId) {
                    int newIndex;
                    // getPointerCount()此时还是包含松手的手指数算在内
                    if (actionIndex == event.getPointerCount() - 1) {
                        newIndex = event.getPointerCount() - 2;
                    } else {
                        newIndex = event.getPointerCount() - 1;
                    }
                    trackingPointerId = event.getPointerId(newIndex);
                    updateOffset(event, newIndex);
                }
                break;
        }
        return true;
    }

    private void updateOffset(MotionEvent event, int index) {
        downX = event.getX(index);
        downY = event.getY(index);
        originalOffsetX = offsetX;
        originalOffsetY = offsetY;
    }

	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmap, offsetX, offsetY, paint);
    }
}

在这里插入图片描述

配合型/协作型

配合型/协作型:所有触摸到 View 的 pointer 共同起作用。典型:ScaleGestureDetector、GestureDetector 的 onScroll() 判断。

配合型/协作型的场景说明简单来说就是:假设有多个手指触摸 View,选择多个手指的中心位置作为焦点坐标处理 View。

实现方式:在每个 MotionEvent.MotionEvent.ACTION_DOWN、MotionEvent.ACTION_POINTER_DOWN、MotionEvent.ACTION_POINTER_UP、MotionEvent.ACTION_UP 事件中使用所有 pointer 的坐标来共同更新焦点坐标,并在 MotionEvent.ACTION_MOVE 中使用所有 pointer 的坐标来判断位置。

demo:多个手指触摸 View 协同移动图片

public class MultiTouchView extends View {
	private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Bitmap bitmap;
    private float downX;
    private float downY;
    private float originalOffsetX;
    private float originalOffsetY;
    private float offsetX;
    private float offsetY;

	public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    	bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.picture);
    }    

	// 多指协作型(通过多个手指的横纵坐标计算出她们的中心点坐标,以中心点坐标作为滑动坐标)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float sumX = 0;
        float sumY = 0;
        // 多指滑动松开时,因为ACTION_POINTER_UP此时还在进行中,所以获取的event.getPointerCount()数量是不对的
        // 比如现在两个手指在滑动bitmap,现在松开一个手指,event.getPointerCount()还是2
        // 如果不过滤ACTION_POINTER_UP松开的手指数量就会导致计算错误,出现bitmap位置跳一下到当前手指触摸位置的问题
        boolean isPointerUp = event.getActionMasked() == MotionEvent.ACTION_POINTER_UP;
        for (int i = 0; i < event.getPointerCount(); i++) {
            // 不是抬起事件,计算没有抬起的手指的横纵坐标
            if (!isPointerUp || i != event.getActionIndex()) {
                sumX += event.getX(i);
                sumY += event.getY(i);
            }
        }
        int pointerCount = event.getPointerCount();
        if (isPointerUp) pointerCount--; // 将抬起的手指的数量去掉
        // 计算多个手指的协同的中心位置,即用多个手指的横纵坐标的和 / 点数量 求出平均中心点坐标
        // 用中心点坐标作为bitmap的移动位置
        float focusX = sumX / pointerCount;
        float focusY = sumY / pointerCount;
        // 因为现在只要关注多指触摸计算的中心点坐标,所以按下、多指按下、多指松开的操作都是重新计算中心点坐标
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
            case MotionEvent.ACTION_POINTER_UP:
                downX = focusX;
                downY = focusY;
                originalOffsetX = offsetX;
                originalOffsetY = offsetY;
                break;
            case MotionEvent.ACTION_MOVE:
                offsetX = focusX - downX + originalOffsetX;
                offsetY = focusY - downY + originalOffsetY;
                invalidate();
                break;
        }
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawBitmap(bitmap, offsetX, offsetY, paint);
    }
}

在这里插入图片描述

各自为战型

各自为战型:各个 pointer 做不同的事,互不影响。典型:支持多画笔的画板应用。

实现方式:在每个 MotionEvent.ACTION_DOWN、MotionEvent.ACTION_POINTER_DOWN 中记录下每个 pointe r的 id,在 MotionEvent.ACTION_MOVE 中使用 id 对它们进行跟踪。

demo:多指触摸 View 实现画板

public class MultiTouchView extends View {
    private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private SparseArray<Path> paths = new SparseArray<>();

    public MultiTouchView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    {
        paint.setStrokeWidth(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4, getResources().getDisplayMetrics()));
        paint.setStyle(Paint.Style.STROKE);
        paint.setStrokeCap(Paint.Cap.ROUND);
        paint.setStrokeJoin(Paint.Join.ROUND);
    }

    // 多指画板
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                int actionIndex = event.getActionIndex();
                int pointerId = event.getPointerId(actionIndex);
                Path path = new Path();
                path.moveTo(event.getX(actionIndex), event.getY(actionIndex));
                paths.append(pointerId, path);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE:
                for (int i = 0; i < event.getPointerCount(); i++) {
                    path = paths.get(i);
                    if (path != null) {
                        path.lineTo(event.getX(i), event.getY(i));
                    }
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                actionIndex = event.getActionIndex();
                pointerId = event.getPointerId(actionIndex);
                paths.remove(pointerId);
                invalidate();
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        for (int i = 0; i < paths.size(); i++) {
            Path path = paths.valueAt(i);
            if (path != null) {
                canvas.drawPath(path, paint);
            }
        }
    }
}

在这里插入图片描述

结合多点触控的触摸事件结构总结

  • 触摸事件是按序列来分组的,每一组事件必然以 MotionEvent.ACTION_DOWN 开头,以 MotionEvent.ACTION_UP 或 MotionEvent.ACTION_CANCEL 结束

  • MotionEvent.ACTION_POINTER_DOWN 和 MotionEvent.ACTION_POINTER_UP 和 MotionEvent.ACTION_MOVE 一样,只是事件序列中的组成部分,并不会单独分出新的事件序列

  • 触摸事件序列是针对 View 的,而不是针对 pointer 的。【某个 pointer 的事件】这种说法是不正确的

  • 在一个触摸事件里,每个 pointer 除了 x 和 y 之外,还有 index 和 id

  • 【移动的那个手指】这个概念是伪概念,【寻找移动的那个手指】这个需求是个伪需求

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值