接着上文,我们做了一个简陋的下拉刷新控件,目前用到的知识点有
- view的滑动
- view的弹性滑动
- 事件分发机制
- 事件分发机制的两个小问题(事件的二次分发)
目前这个控件除了简陋一点,没做抽象封装,在单手操作下,表现还是不错的,但是多手操作试一下,页面会产生位移突变。这就引出了本节的多点触摸知识点
多点触摸的原理明白后,一般只是用来处理多点触摸所引起的bug,一般不会使用多点触摸来处理缩放等高级的多点触摸问题,因为毕竟太麻烦了,我们有系统封装好的GestureDetector、ScaleGestureDetector、GestureDetector.SimpleOnGestureListener
首先推荐这个大神的博客
安卓自定义View进阶-MotionEvent详解
安卓自定义View进阶-多点触控详解
以及这个官方教程
拖拽与缩放
知识点
前奏开始了
假设大家已经认真阅读了上边的博客,下面列出必知必会的知识点:
-
多点触控获取事件类型请使用 getActionMasked()
-
每一根手指有两个标记,index和pointId,
index会随着之前手指的抬起而发生变化,pointId不会发生变化
-
说一下多指触摸下的事件流
第一根手指按下: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
-
重要的api
盗图了,来自安卓自定义View进阶-MotionEvent详解
// 获取index,在move时候,此方法无效,只能在ACTION_DOWN,// ACTION_POINTER_DOWN,ACTION_POINTER_UP,// ACTION_UP里得到的index才是有效的int action_index = event.getActionIndex();// 通过index得到该手指的idint action_id = event.getPointerId(action_index);// 得到事件类型int action = event.getActionMasked();// 得到指定索引手指的y坐标float y = event.getY(activeIndex);
这才是最难理解的
关于多指操作时候,每一根手指的index和id的变化情况
我这里通过log来演示,log不包含move的情况,因为move不区分手指的index和id
- 前三步很好理解,依次按下三根手指,index和id依次递增(注意第三根手指index为2,id为2)
- 第四步抬起了第二根手指,index和id均显示1,也很正常
- 第五步按下第四根手指(重点1),index和id均显示1,【他填补了第二根手指释放的的index和id】
- 第六步,抬起第一根手指,index和id均显示0,也算正常
- 第七步,抬起第三根手指(重点2),index为1,id为2,但是当第三根手指按下时,index为2,id为2,【发现index变了,但是id没变】
- 第八步,抬起第四根手指,index为0,id为1,但是当第四根手指按下时,index为1,id为1,【同样发现index变了,但是id没变】
正是这种看似很奇怪的现象,导致我们追踪每一根手指的行为变得比较困难
观察log得出的结论是:
要想追踪手指,必须跟踪id,index会随着其他手指的抬起发生变化
这种变化,应该是,每次抬起一根手指,所有的手指的index中,比这根抬起的手指index大的都减去一,比他小的保持不变
应用:
如何跟踪指定的一根手指,无论其他手指如何起起落落,我都要追踪某一根手指。
大概思路:
- 每次down或ACTION_POINTER_DOWN时,通过
index=event.getActionIndex()
得到该手指的index,然后通过id=event.getPointerId(index)
得到该手指的id,然后你记住这个id,存起来作比对用 - 然后现在要move了,你先得到手指数量
count=event.getPointerCount()
,再去遍历所有手指,此时遍历用到的是index,通过index得到id,看这个id是不是你要追踪的id,如果是的话,记住对应的index,然后通过y = event.getY(curActiveIndex)
得到坐标信息,然后操作就行了 - 为什么上面,每次都是要找一次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处理原则是:
- 第一根手指按下滑动,页面响应滑动事件
- 第二根手指按下滑动时,页面响应滑动事件,但是此时第一根手指滑动不会导致页面滑动
- 第三根手指按下滑动时,页面响应滑动事件,但是此时第一根和第二根手指滑动都不会导致页面滑动
- 第四根手指按下滑动时,页面响应滑动事件,但是此时第一根、第二根和第三根手指滑动都不会导致页面滑动
结论:在多根手指依次按到页面上时,追踪的是最新的那根手指的滑动事件 - 现在页面上有四根手指,松开第三根手指,发现,依然第四根手指控制滑动,其余的手指滑动无效
- 现在页面上有三根手指,松开第四根手指(也就是当前控制滑动的手指),发现,第一根手指控制滑动,其余的手指滑动无效
- 结论:在多根手指按到页面上时,如果松开的是非操控手指,那么操控权依然是刚才的操控手指,如果松开的是当前的操控手指,那么把操控权,交给index为0的手指,即第一根手指
ok,我们就来实现以下,上面的多指触摸逻辑
代码
源码
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() {@Overridepublic 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,scrollViewView child1 = getChildAt(1);if (child1 instanceof ScrollView) {mScrollView = (ScrollView) child1;}}}float mLastY;@Overridepublic 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;}@Overridepublic void requestDisallowInterceptTouchEvent(boolean b) {// 去掉默认行为,使得每个事件都会经过这个Layout}int curActiveId = 0;// 当前操作滑动的手指的idint lastActiveId = 0;//上次操作滑动的手指的idint curActiveIndex = 0;//当前操作滑动的手指的index@Overridepublic 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()) {//一定要用getActionMaskedcase MotionEvent.ACTION_DOWN:break;case MotionEvent.ACTION_POINTER_DOWN://新手指按下,让它成为控制手指,更新下当前的控制手指的indexcurActiveIndex = 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;}// 动态改变刷新头的topMarginmLp.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;//别忘了,更新上次的操控手指idreturn true;}/*** 使用属性动画平滑地过度topMargin** @param start* @param end*/private void smoothChangeTopMargin(int start, int end) {ValueAnimator valueAnimator = ValueAnimator.ofInt(start, end);valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mLp.topMargin = (int) animation.getAnimatedValue();mHeader.setLayoutParams(mLp);}});valueAnimator.setDuration(300);valueAnimator.start();}
}