多点触摸处理

接着上文,我们做了一个简陋的下拉刷新控件,目前用到的知识点有

  1. view的滑动
  2. view的弹性滑动
  3. 事件分发机制
  4. 事件分发机制的两个小问题(事件的二次分发)

目前这个控件除了简陋一点,没做抽象封装,在单手操作下,表现还是不错的,但是多手操作试一下,页面会产生位移突变。这就引出了本节的多点触摸知识点

多点触摸的原理明白后,一般只是用来处理多点触摸所引起的bug,一般不会使用多点触摸来处理缩放等高级的多点触摸问题,因为毕竟太麻烦了,我们有系统封装好的GestureDetector、ScaleGestureDetector、GestureDetector.SimpleOnGestureListener

首先推荐这个大神的博客
安卓自定义View进阶-MotionEvent详解
安卓自定义View进阶-多点触控详解
以及这个官方教程
拖拽与缩放

知识点

前奏开始了

假设大家已经认真阅读了上边的博客,下面列出必知必会的知识点:

  1. 多点触控获取事件类型请使用 getActionMasked()

  2. 每一根手指有两个标记,index和pointId,

    index会随着之前手指的抬起而发生变化,pointId不会发生变化

  3. 说一下多指触摸下的事件流

    第一根手指按下:ACTION_DOWN(0x00000000)
    第二根手指按下:ACTION_POINTER_DOWN(0x00000105)
    第三根手指按下:ACTION_POINTER_DOWN(0x00000205)
    任意一根手指滑动:ACTION_MOVE(0x00000002)
    第三根手指抬起:ACTION_POINTER_UP(0x00000206)
    第二根手指抬起:ACTION_POINTER_UP(0x00000106)
    第一根手指抬起:ACTION_UP(0x00000001)

    只有第一根手指按下会调用ACTION_DOWN,其余手指按下,会调用ACTION_POINTER_DOWN,而且ACTION_POINTER_DOWN最后的105,5代表事件的类型(多指按下事件),那个1是该手指的index,同理205中的2代表第二根手指的index。

    当手指move时候,没有对应的事件类型,表明你在move哪根手指,都用(0x00000002)表示

    当最后一根手指抬起时,才会触发ACTION_UP,其余手指抬起来,触发的是ACTION_POINTER_UP,事件类型为6,前面的是手指index

  4. 重要的api
    盗图了,来自安卓自定义View进阶-MotionEvent详解
    多点触摸处理_第1张图片

 // 获取index,在move时候,此方法无效,只能在ACTION_DOWN,
 // ACTION_POINTER_DOWN,ACTION_POINTER_UP,
 // ACTION_UP里得到的index才是有效的
 int action_index = event.getActionIndex();
 // 通过index得到该手指的id
 int action_id = event.getPointerId(action_index);
 // 得到事件类型
 int action = event.getActionMasked();
 // 得到指定索引手指的y坐标
 float y = event.getY(activeIndex);

这才是最难理解的

关于多指操作时候,每一根手指的index和id的变化情况
我这里通过log来演示,log不包含move的情况,因为move不区分手指的index和id

多点触摸处理_第2张图片

  1. 前三步很好理解,依次按下三根手指,index和id依次递增(注意第三根手指index为2,id为2)
  2. 第四步抬起了第二根手指,index和id均显示1,也很正常
  3. 第五步按下第四根手指(重点1),index和id均显示1,【他填补了第二根手指释放的的index和id】
  4. 第六步,抬起第一根手指,index和id均显示0,也算正常
  5. 第七步,抬起第三根手指(重点2),index为1,id为2,但是当第三根手指按下时,index为2,id为2,【发现index变了,但是id没变】
  6. 第八步,抬起第四根手指,index为0,id为1,但是当第四根手指按下时,index为1,id为1,【同样发现index变了,但是id没变】

正是这种看似很奇怪的现象,导致我们追踪每一根手指的行为变得比较困难
观察log得出的结论是:
要想追踪手指,必须跟踪id,index会随着其他手指的抬起发生变化

这种变化,应该是,每次抬起一根手指,所有的手指的index中,比这根抬起的手指index大的都减去一,比他小的保持不变

应用:

如何跟踪指定的一根手指,无论其他手指如何起起落落,我都要追踪某一根手指。

大概思路:

  1. 每次down或ACTION_POINTER_DOWN时,通过index=event.getActionIndex()得到该手指的index,然后通过id=event.getPointerId(index)得到该手指的id,然后你记住这个id,存起来作比对用
  2. 然后现在要move了,你先得到手指数量count=event.getPointerCount(),再去遍历所有手指,此时遍历用到的是index,通过index得到id,看这个id是不是你要追踪的id,如果是的话,记住对应的index,然后通过y = event.getY(curActiveIndex)得到坐标信息,然后操作就行了
  3. 为什么上面,每次都是要找一次index,因为你不能直接通过event得到id和坐标值,必须通过index来得到,但是index又是不可靠的,老变化,不变的只有id,所以又要去比对id。

另一种思路,是每次当手指抬起时ACTION_POINTER_UP,去实时地算出你要追踪的手指的index。因为我们知道index小于抬起手指index的手指,index不变,大于的需要减去一,这样你可以准确地直接追踪index,再拿着index去都得到坐标,id就不用管了

回归到本例,如何解决位移突变问题

现象:

当一根手指下拉到一定位置时,另外一根手指按到屏幕上,然后松开第一根手指,发现,位移突变了

原因:

先看看这两个方法

float getY()

默认取index为0的手指的坐标
在这里插入图片描述

getY(int pointerIndex)

取出指定index的手指的坐标在这里插入图片描述

当我们一根手指下拉时(此刻这根手指的index为0),getY(),获取到的自然是这唯一的手指的坐标,
当第二根手指按住屏幕时(此刻这根手指的index为1),这根手指的Y坐标与之前的手指坐标里的较远,
此时第一根手指一松手,按照前面我们的分析,第二根手指的index马上变为0,那么getY,就会取到这个手指的坐标,然而他距离上一次的Y坐标离得很远了,所以deltaY=y-mLastY,deltaY会很大,导致位移突变

如何解决呢?

解决思路肯定是多点触摸了,但是你要解决城什么样子呢?拿出你的手机,随便翻出一个ScrollView或者RecyclerView,你多指触摸,仔细看看,发现系统的View处理原则是:

  1. 第一根手指按下滑动,页面响应滑动事件
  2. 第二根手指按下滑动时,页面响应滑动事件,但是此时第一根手指滑动不会导致页面滑动
  3. 第三根手指按下滑动时,页面响应滑动事件,但是此时第一根和第二根手指滑动都不会导致页面滑动
  4. 第四根手指按下滑动时,页面响应滑动事件,但是此时第一根、第二根和第三根手指滑动都不会导致页面滑动
    结论:在多根手指依次按到页面上时,追踪的是最新的那根手指的滑动事件
  5. 现在页面上有四根手指,松开第三根手指,发现,依然第四根手指控制滑动,其余的手指滑动无效
  6. 现在页面上有三根手指,松开第四根手指(也就是当前控制滑动的手指),发现,第一根手指控制滑动,其余的手指滑动无效
  7. 结论:在多根手指按到页面上时,如果松开的是非操控手指,那么操控权依然是刚才的操控手指,如果松开的是当前的操控手指,那么把操控权,交给index为0的手指,即第一根手指

ok,我们就来实现以下,上面的多指触摸逻辑

代码

多点触摸处理_第3张图片
多点触摸处理_第4张图片

多点触摸处理_第5张图片

源码

package com.view.custom.dosometest.view;

import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;

/**
 * 描述当前版本功能
 *
 * @Project: DoSomeTest
 * @author: cjx
 * @date: 2019-12-01 10:06  星期日
 */
public class RefreshView extends LinearLayout {


    private ScrollView mScrollView;
    private View mHeader;
    private int mHeaderHeight;
    private MarginLayoutParams mLp;

    public RefreshView(Context context) {
        super(context);
        init(context);
    }

    public RefreshView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public RefreshView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }


    private void init(Context context) {
        setBackgroundColor(Color.GRAY);

        post(new Runnable() {
            @Override
            public void run() {
                initView();// 因为涉及到获取控件宽高的问题,所以写到post里
            }
        });

    }

    private void initView() {

        if (getChildCount() > 2) {

            // 给刷新头设置负高度的margin,让他隐藏
            mHeader = getChildAt(0);
            mHeaderHeight = mHeader.getMeasuredHeight();
            mLp = (MarginLayoutParams) mHeader.getLayoutParams();
            mLp.topMargin = -mHeaderHeight;
            mHeader.setLayoutParams(mLp);

            // 得到第二个view,scrollView
            View child1 = getChildAt(1);
            if (child1 instanceof ScrollView) {
                mScrollView = (ScrollView) child1;
            }

        }
    }


    float mLastY;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {

        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;

            case MotionEvent.ACTION_MOVE:
                int deltaY = (int) (y - mLastY);
                if (needIntercept(deltaY)) {//外部拦截的模板代码,只要重写needIntercept方法逻辑就行
                    //注意当前ViewGroup一旦拦截,一次事件序列中就再也不会调用onInterceptTouchEvent了,
                    // 所以子View再也不会得到事件处理的机会了
                    // 为了解决这个问题,就引出了《嵌套滑动》这个新的事物,见下文
                    intercept = true;
                } else {
                    intercept = false;
                }

                break;

            case MotionEvent.ACTION_UP:

                intercept = false;

                break;
            default:
                break;
        }

        mLastY = y;
        return intercept;
    }

    private boolean needIntercept(int deltaInteceptY) {
        // mScrollView已经下拉到最顶部&&你还在下来,那么父容器拦截
        if (!mScrollView.canScrollVertically(-1) && deltaInteceptY > 0) {
            Log.e("ccc", "不能再往下拉了&&你还在往下拉,父布局拦截,开始拉出刷新头");
            return true;
        }
        if (mLp.topMargin > -mHeaderHeight) {
            Log.e("ccc", "只要顶部刷新头,显示着,就让父布局拦截");
            return true;
        }

        return false;
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean b) {
        // 去掉默认行为,使得每个事件都会经过这个Layout
    }

    int curActiveId = 0;// 当前操作滑动的手指的id
    int lastActiveId = 0;//上次操作滑动的手指的id
    int curActiveIndex = 0;//当前操作滑动的手指的index

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        int count = event.getPointerCount();
        // 避免索引越界,应该不会越界,判断一下稳妥
        curActiveIndex = (curActiveIndex >= count) ? count - 1 : curActiveIndex;
        curActiveIndex = (curActiveIndex < 0) ? 0 : curActiveIndex;
        Log.e("qqq", "curActiveIndex:" + curActiveIndex);
        float y = event.getY(curActiveIndex);//得到操控手指的坐标(只是关心操控手指)
        curActiveId = event.getPointerId(curActiveIndex);
        //下面判断手指是不是同一个,必须用id,因为index随时会变的
        if (curActiveId != lastActiveId) {//判断当前操控手指id和上次操控手指id是不是一样
            mLastY = y;//★★★如果不一样,马上把此刻的y坐标赋值给上次的y坐标,这是避免位移突变的关键点
        }

        switch (event.getActionMasked()) {//一定要用getActionMasked
            case MotionEvent.ACTION_DOWN:

                break;

            case MotionEvent.ACTION_POINTER_DOWN:
                //新手指按下,让它成为控制手指,更新下当前的控制手指的index
                curActiveIndex = event.getActionIndex();
                break;

            case MotionEvent.ACTION_POINTER_UP:

                int upIndex = event.getActionIndex();
                Log.e("qqq", "upIndex:" + upIndex + " curActiveIndex:" + curActiveIndex);
                if (curActiveIndex > upIndex) {
                    // 如果当前控制手指的index>抬起的手指index,需要减去一(很关键,博客分析过)
                    curActiveIndex = curActiveIndex - 1;
                } else if (curActiveIndex == upIndex) {
                    // 如果相等,说明你抬起来的就是操控手指,那么变更操控手指为第一根手指
                    curActiveIndex = 0;
                }

                break;


            case MotionEvent.ACTION_MOVE:
                float deltaY = y - mLastY;

                // 防止刷新头被无限制下拉,限定个高度

                if (mLp.topMargin + deltaY > mHeaderHeight) {
                    deltaY = mHeaderHeight - mLp.topMargin;
                }
                // 动态改变刷新头的topMargin
                mLp.topMargin += (int) deltaY;
                Log.e("ccc", "y:" + y + "mLastY:" + mLastY + "deltaY:" + deltaY + "mLp.topMargin:" + mLp.topMargin);
                mHeader.setLayoutParams(mLp);

                if (mLp.topMargin <= -mHeaderHeight && deltaY < 0) {
                    // 重新dispatch一次down事件,使得列表可以继续滚动
                    int oldAction = event.getAction();
                    event.setAction(MotionEvent.ACTION_DOWN);
                    dispatchTouchEvent(event);
                    event.setAction(oldAction);
                }
                break;


            case MotionEvent.ACTION_UP:
                //松手后,看位置,如果过半,刷新头全部显示,没过半,刷新头全部隐藏
                if (mLp.topMargin > -mHeaderHeight / 2) {
                    smoothChangeTopMargin(mLp.topMargin, 0);
                } else {
                    smoothChangeTopMargin(mLp.topMargin, -mHeaderHeight);
                }

                break;
        }

        mLastY = y;
        lastActiveId = curActiveId;//别忘了,更新上次的操控手指id

        return true;
    }

    /**
     * 使用属性动画平滑地过度topMargin
     *
     * @param start
     * @param end
     */
    private void smoothChangeTopMargin(int start, int end) {
        ValueAnimator valueAnimator = ValueAnimator.ofInt(start, end);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mLp.topMargin = (int) animation.getAnimatedValue();
                mHeader.setLayoutParams(mLp);

            }
        });
        valueAnimator.setDuration(300);
        valueAnimator.start();

    }
}

你可能感兴趣的:(自定义View)