Android 多点触控最佳实践

6,078 阅读9分钟
原文链接: www.jianshu.com

  好久没有更新自己的简书博客了,说来真是惭愧,感觉自己太点懒了。话说其实坚持写博客还是很有用的,可以梳理自己的知识,帮助自己加深印象,找工作的时候也算是一点筹码,起码能够说明热爱技术并且真的有涉及过,而不是整天在公司混日子,所以今后我应该会增加博客的更新频率。接下来是正题。


事故现场

  最近在对公司项目中的控件进行优化改造,其中一个是能够上拉和下拉的弹性ScrollView。


  发现没有,当使用一个手指的时候感觉还不错。但是当我想用两个手指交替不断下拉想要把视图内容往下“扒”的时候就办不到了,因为他在onTouchEvent()方法里只是最简单的实现了下拉的逻辑而没有涉及到多指触控,而我想要的效果是像QQ空间或者微信朋友圈那样的。


预备知识

为了将控件改造成能够支持多点触控的,首先我们需要了解Android中关于多点触控的基础知识。这里我推荐先阅读GcsSloop的两篇文章
安卓自定义View进阶-MotionEvent详解
安卓自定义View进阶-多点触控详解
  我现在假设你已经看了上面的那两篇文章,下面我来划重点:
1.多点触控时必须使用getActionMasked()来获取事件类型
2.Pointer:
MotionEvent中引入了Pointer的概念,一个pointer就代表一个触摸点,每个pointer都有自己的事件类型,也有自己的横轴坐标值。一个MotionEvent对象中可能会存储多个pointer的相关信息,每个pointer都会有一个自己的id和index。pointer的id在整个事件流中是不会发生变化的,但是index会发生变化
3.PointerId:
每根手指从按下、移动到离开屏幕,每个手指都会拥有一个固定PointerId.PointerId的值,一般用它来区分是哪根手指
4.PointerIndex:
每根手指从按下、移动到离开屏幕,每根手指在每一个事件的Index可能是不固定的,因为受到其它手指的影响
5.PointerId和PointerIndex的变化规律
关于变化规律这里,可以看GcsSloop的第二篇文章,这里我不再赘述,只举一个实际的例子:

事件 PointerId PointerIndex
依次按下三根手指 三根手指的id依次为0、1、2 三根手指的index依次为0、1、2
抬起第二根手指 第一根手指的id为0,第三根手指的id为2 第一根手指的index为0,第三根手指的index变为1
抬起第一根手指 第三根手指的id为2 第三根手指的index变为0

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

6.多点触控相关事件

事件 简介
ACTION_DOWN 第一个手指初次接触到屏幕时触发
ACTION_MOVE 手指在屏幕上滑动时触发,会多次触发。
ACTION_UP 最后一个手指离开屏幕时触发
ACTION_POINTER_DOWN 有非主要的手指按下(即按下之前已经有手指在屏幕上)
ACTION_POINTER_UP 有非主要的手指抬起(即抬起之后仍然有手指在屏幕上)

7.多点触控相关的方法:

方法 简介
getActionMasked() 与 getAction() 类似,多点触控需要使用这个方法获取事件类型
getActionIndex() 获取该事件是哪个指针(手指)产生的
getPointerId(int pointerIndex) 获取一个指针(手指)的唯一标识符ID,在手指按下和抬起之间ID始终不变
getX(int pointerIndex) 获取某一个指针(手指)的X坐标
getY(int pointerIndex) 获取某一个指针(手指)的Y坐标
findPointerIndex(int pointerId) 通过PointerId获取到当前状态下PointIndex,之后通过PointIndex获取其他内容
getPointerCount() 获取在屏幕上手指的个数

如何使用多点触控

对于多点触控的处理,一般是这样: 记录活动手指的id(mActivePointerId),通过此id获取move事件的坐标

  1. 在手指按下的时候,记录下activePointerId
  2. 第二根手指按下的时候,更新activePointerId(我们让第二根手指作为活动手指,忽略第一个手指的move)
  3. 当其中一根手指抬起时,如果是第一根手指,那么不做处理,如果是第二根手指抬起,也就是活动手指抬起的话,将活动手指改回第一根
    我这里还是举一个GcsSloop文章中的例子:
    如果我们需要一个可以用单指拖动的图片。假如我们不进行多指触控的判断,像下面这样:
    没有针对多指触控处理版本:
public class DragViewSingleTouch extends View {
    String TAG = "DragViewSingleTouch";

    Bitmap mBitmap;         // 图片
    RectF mBitmapRectF;     // 图片所在区域
    Matrix mBitmapMatrix;   // 控制图片的 matrix

    boolean canDrag = false;
    PointF lastPoint = new PointF(0, 0);
    private Paint mDeafultPaint;

    public DragViewSingleTouch(Context context) {
        this(context, null);
    }

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

        mDeafultPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

        // 调整图片大小
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.outWidth = 960/2;
        options.outHeight = 800/2;

        mBitmap = BitmapFactory.decodeResource(this.getResources(), R.drawable.poly_test, options);
        mBitmapRectF = new RectF(0,0,mBitmap.getWidth(), mBitmap.getHeight());
        mBitmapMatrix = new Matrix();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                // 判断按下位置是否包含在图片区域内
                if (mBitmapRectF.contains((int)event.getX(), (int)event.getY())){
                    canDrag = true;
                    lastPoint.set(event.getX(), event.getY());
                }
                break;
            case MotionEvent.ACTION_UP:
                canDrag = false;
            case MotionEvent.ACTION_MOVE:
                if (canDrag) {
                    // 移动图片
                    mBitmapMatrix.postTranslate(event.getX() - lastPoint.x, event.getY() - lastPoint.y);
                    // 更新上一次点位置
                    lastPoint.set(event.getX(), event.getY());

                    // 更新图片区域
                    mBitmapRectF = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
                    mBitmapMatrix.mapRect(mBitmapRectF);

                    invalidate();
                }
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawBitmap(mBitmap, mBitmapMatrix, mDeafultPaint);
    }

}

这个版本非常简单,当然了,如果正常使用(只使用一个手指)的话也不会出问题,但是当使用多个手指,且有抬起和按下的时候就可能出问题



  注意在第二个手指按下,第一个手指抬起时,此时原本的第二个手指会被识别为第一个,所以图片会直接跳动到第二个手指位置。原因是event.getX()和event.getY中没有传入pointerIndex的参数, 那么默认追踪的就是pointerIndex = 0的手指,当第二个手指按下,第一个手指抬起的时候,触发了move事件,event.getX()和event.getY()此时是获取第二个手指的数据,而lastPoint.x和lastPoint.y并没有在第二个手指按下的时候进行更新,记录的是第一个手指抬起时候的坐标,和evet.getX()、event.getY()有较大的距离, 所以postTranslate了很大一段距离, 发生了跳动的情况。
  为了不出现这种情况,我们可以判断一下 pointId 并且只获取第一个手指的数据,这样就能避免这种情况发生了,如下。
针对多指触控处理后版本:

public class DragViewUpGrade extends View {
    String TAG = "DragViewUpGrade";

    Bitmap mBitmap;         // 图片
    RectF mBitmapRectF;     // 图片所在区域
    Matrix mBitmapMatrix;   // 控制图片的 matrix

    boolean canDrag = false;
    PointF lastPoint = new PointF(0, 0);

    public DragViewUpGrade(Context context) {
        this(context, null);
    }

    public DragViewUpGrade(Context context, AttributeSet attrs) {
        super(context, attrs);

        mDeafultPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

        BitmapFactory.Options options = new BitmapFactory.Options();
        options.outWidth = 960/2;
        options.outHeight = 800/2;

        mBitmap = BitmapFactory.decodeResource(this.getResources(), R.drawable.poly_test, options);
        mBitmapRectF = new RectF(0,0,mBitmap.getWidth(), mBitmap.getHeight());
        mBitmapMatrix = new Matrix();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_POINTER_DOWN:
                // ▼ 判断是否是第一个手指 && 是否包含在图片区域内
                if (event.getPointerId(event.getActionIndex()) == 0 && mBitmapRectF.contains((int)event.getX(), (int)event.getY())){
                    canDrag = true;
                    lastPoint.set(event.getX(), event.getY());
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_POINTER_UP:
                // ▼ 判断是否是第一个手指
                if (event.getPointerId(event.getActionIndex()) == 0){
                    canDrag = false;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                // 如果存在第一个手指,且这个手指的落点在图片区域内
                if (canDrag) {
                    // ▼ 注意 getX 和 getY
                    // 只找第一根手指
                    int index = event.findPointerIndex(0);
                    // Log.i(TAG, "index="+index);
                    mBitmapMatrix.postTranslate(event.getX(index)-lastPoint.x, event.getY(index)-lastPoint.y);
                    lastPoint.set(event.getX(index), event.getY(index));

                    mBitmapRectF = new RectF(0,0,mBitmap.getWidth(), mBitmap.getHeight());
                    mBitmapMatrix.mapRect(mBitmapRectF);

                    invalidate();
                }
                break;
        }

        return true;
    }

    private Paint mDeafultPaint;

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawBitmap(mBitmap, mBitmapMatrix, mDeafultPaint);
    }
}

这个也是GcsSloop的代码,因为这里只追踪第一根手指(pointerId = 0的手指),第二根手指的活动全都无视,所以不会再出现跳动的情况



  但是我觉得依旧不够,因为我想要的是当第二根手指放下的时候就可以靠第二根手指来移动图片,而不是无视第二根手指。这是就产生了最终的版本,是我在GcsSloop的代码基础上加以改进的,如下:

public class DragViewFinal extends View {
    String TAG = "DragViewFinal";

    Bitmap mBitmap;         // 图片
    RectF mBitmapRectF;     // 图片所在区域
    Matrix mBitmapMatrix;   // 控制图片的 matrix

    boolean canDrag = false;
    PointF lastPoint = new PointF(0, 0);
    private Paint mDeafultPaint;

    public DragViewFinal(Context context) {
        this(context, null);
    }

    public DragViewFinal(Context context, AttributeSet attrs) {
        super(context, attrs);

        mDeafultPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

        // 调整图片大小
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.outWidth = 960 / 2;
        options.outHeight = 800 / 2;

        mBitmap = BitmapFactory.decodeResource(this.getResources(), R.drawable.poly_test, options);
        mBitmapRectF = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
        mBitmapMatrix = new Matrix();
    }

    private int mActivePointerId;
    /**
     * A null/invalid pointer ID.
     */
    private final int INVALID_POINTER = -1;

    // 记录活动手指的id(activePointerId),通过此ID获取move事件的坐标。
    // 在手指按下的时候,记录下activePointerId
    // 第二根手指按下的时候,更新activePointerId。(我们让第二根手指作为活动手指,忽略第一个手指的move)
    // 当其中一根手指抬起时,如果是第一根手指,那么不做处理,如果是第二根手指抬起,也就是活动手指抬起的话,将活动手指改回第一根。
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        final int actionIndex = event.getActionIndex();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 判断按下位置是否包含在图片区域内
                if (mBitmapRectF.contains((int) event.getX(), (int) event.getY())) {
                    mActivePointerId = event.getPointerId(0);
                    Log.d("ACTION_DOWN", "mActivePointerId = " + mActivePointerId);
                    canDrag = true;
                    lastPoint.set(event.getX(0), event.getY(0));
                }
                break;
            case MotionEvent.ACTION_POINTER_DOWN:
                // 将新落下来那根手指作为活动手指
                mActivePointerId = event.getPointerId(actionIndex);
                lastPoint.set(event.getX(actionIndex), event.getY(actionIndex));
                Log.d("ACTION_POINTER_DOWN", "mActivePointerId = " + mActivePointerId);
                break;
            case MotionEvent.ACTION_POINTER_UP:
                if (mActivePointerId == event.getPointerId(actionIndex)) { // 如果松开的是活动手指, 让还停留在屏幕上的最后一根手指作为活动手指
                    // This was our active pointer going up. Choose a new
                    // active pointer and adjust accordingly.
                    // pointerIndex都是像0, 1, 2这样连续的
                    final int newPointerIndex = actionIndex == 0 ? 1 : 0;
                    mActivePointerId = event.getPointerId(newPointerIndex);
                    lastPoint.set(event.getX(newPointerIndex), event.getY(newPointerIndex));
                    Log.d("ACTION_POINTER_UP", "松开的是活动手指");
                }
                Log.d("ACTION_POINTER_UP", "mActivePointerId = " + mActivePointerId);
                break;
            case MotionEvent.ACTION_UP: // 代表用户的最后一个手指离开了屏幕
                mActivePointerId = INVALID_POINTER;
                canDrag = false;
                Log.d("ACTION_UP", "mActivePointerId = " + mActivePointerId);
            case MotionEvent.ACTION_MOVE:
                if (mActivePointerId == INVALID_POINTER) {
                    Log.e("ACTION_MOVE", "Got ACTION_MOVE event but don't have an active pointer id.");
                    return false;
                }
                if (canDrag) {
                    final int pointerIndex = event.findPointerIndex(mActivePointerId);
                    mBitmapMatrix.postTranslate(event.getX(pointerIndex) - lastPoint.x, event.getY(pointerIndex) - lastPoint.y);
                    // 更新上一次点位置
                    lastPoint.set(event.getX(pointerIndex), event.getY(pointerIndex));
                    // 更新图片区域
                    mBitmapRectF = new RectF(0, 0, mBitmap.getWidth(), mBitmap.getHeight());
                    mBitmapMatrix.mapRect(mBitmapRectF);
                    invalidate();
                }
                break;
        }

        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        canvas.drawBitmap(mBitmap, mBitmapMatrix, mDeafultPaint);
    }
}

注意MotionEvent.ACTION_POINTER_DOWN和MotionEvent.ACTION_POINTER_UP事件:在新的一根手指落下来的时候,将这根新的手指作为活动手指,记录它的pointerId并且更新lastPoint的坐标;在其中一根手指抬起的时候进行判断,如果抬起的是非活动手指,那就不要管,如果抬起的时候活动手指,那就把其他的手指作为活动手指。选择新的活动手指的时候,我这里简单粗暴的用了final int newPointerIndex = actionIndex == 0 ? 1 : 0这样的形式,因为我暂时想不到更好的方法了,如果你有,请告诉我。效果如下:


多点触控了解的差不多了,接下来我就对那个只支持单点触控的弹性ScrollView进行改进了,但是因为这里涉及到了一些其他的知识,就不贴代码了,但对于多点触控的处理还是核心的那几步。其实写代码都是那样,关键在于理解,理解了之后便能够灵活运用,万变不离其宗。这里只贴出一个改进后的控件的效果:



github地址

最后是国际惯例,给出demo的github地址MutiTouchDemo