滑动冲突是指,当手指在屏幕上进行滑动操作,而这个操作可能会同时作用在多个控件上,并且产生不可预测的结果,这个时候我们就认为这个滑动操作是有冲突的。
举个例子:有一个可以上下滑动的列表,同时这个列表的每个item又是可以左右滑动的,如果我们不进行处理,那么造成的后果就是只有列表能上下滑动或者只有item能左右滑动,这个时候我们就可以认为两者之间是有滑动冲突的。
一、滑动冲突的场景
滑动冲突的场景不外乎以下三种:
- 外部滑动方向与内部滑动方向不一致
- 外部滑动方向与内部滑动方向一致
- 上面两种情况的嵌套
从本质上来说,这三种冲突产生的原因是一致的。尽管场景3看起来稍微复杂一些,其实也就是场景1和场景2嵌套造成的,我们只需要分别处理内层和中层、中层和外层的冲突就可以了。所以不管这些场景如何嵌套,我们只要掌握如何处理场景1和场景2的滑动冲突,问题都能迎刃而解。
二、滑动冲突的处理规则
-
场景1处理规则
当用户上下滑动时,让内部View拦截事件;当用户左右滑动时,让外部View拦截事件,即根据滑动方向是竖直还是水平来决定由谁来拦截事件。我们可以根据坐标来判断滑动的方向,比如根据滑动路径与水平方向的夹角、水平方向与竖直方向滑动距离差、甚至还可以根据水平方向与竖直方向的速度差。
这里我们只讨论采用水平 方向和竖直方向滑动距离差来判断滑动方向的情况。如下图所示,当dx > dy我们认为这是一次水平方向的滑动;当dx < dy我们认为这是一次竖直方向的滑动。
-
场景2处理规则
场景2这种情况一般都是在业务逻辑上寻找解决方案。例如我们平时经常使用的SwipeRefreshLayout,假设我们在SwipeRefreshLayout内部嵌套一个竖直滑动的列表,理论上应该是会有滑动冲突的。但是我们发现SwipeRefreshLayout内部嵌套RecyclerView通常都表现得比较友好,这是因为google已经为我们解决了滑动冲突,我们来看一下google是如何解决SwipeRefreshLayout与它的子View的滑动冲突的。
public boolean onInterceptTouchEvent(MotionEvent ev) {
//确保SwipeRefreshLayout至少有一个child view,并且第一个child赋值给mTarget
this.ensureTarget();
int action = ev.getActionMasked();
if (this.mReturningToStart && action == 0) {
this.mReturningToStart = false;
}
//判断当前SwipeRefreshLayout是否处于可以下拉刷新状态
if (this.isEnabled() && !this.mReturningToStart && !this.canChildScrollUp()
&& !this.mRefreshing && !this.mNestedScrollInProgress) {
int pointerIndex; //整个事件发生过程,pointerIndex小于0时不拦截事件
switch(action) {
case 0://ACTION_DOWN
this.setTargetOffsetTopAndBottom(this.mOriginalOffsetTop - this.mCircleView.getTop());
this.mActivePointerId = ev.getPointerId(0);
//记录是否正在进行拖拽,这是决定事件拦截的关键
this.mIsBeingDragged = false;
pointerIndex = ev.findPointerIndex(this.mActivePointerId);
if (pointerIndex < 0) {
return false;
}
//初始化ACTION_DOWN事件的Y坐标
this.mInitialDownY = ev.getY(pointerIndex);
break;
case 1://ACTION_UP
case 3://ACTION_CANCEL
//取消拖拽
this.mIsBeingDragged = false;
this.mActivePointerId = -1;
break;
case 2://ACTION_MOVE
//mActivePointerId 用于在整个手势中跟踪一个单独的pointer
if (this.mActivePointerId == -1) {
Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
return false;
}
pointerIndex = ev.findPointerIndex(this.mActivePointerId);
if (pointerIndex < 0) {
return false;
}
float y = ev.getY(pointerIndex);
this.startDragging(y); //拖拽并展示圆形进度条
case 4:
case 5:
default:
break;
case 6:
this.onSecondaryPointerUp(ev);
}
return this.mIsBeingDragged;
} else {
return false;
}
}
注释写得我自己都看不懂啊,简而言之就是:当SwipeRefreshLayout处于拖拽状态即mIsBeingDragged为true的时候,拦截事件,否则事件不会被拦截。
总结一下事件拦截过程中各变量的作用(个人理解可能有误,所以个别变量含义会附上文档内英文注释):
- mIsBeingDragged,记录拖拽状态,它的值为true的时候SwipeRefreshLayout拦截事件,否则不拦截。
- mActivePointerId,与此事件中的特定指针数据索引关联的指针标识符, 标识符告诉您与数据关联的实际指针编号,用来标识当前手势开始以来的各个指针(因为可能有多点触摸事件)。简单地说就是一个pointer的id,如果它等于-1就不拦截当前事件。
英文描述:the pointer identifier associated with a particular pointer data index in this event. The identifier tells you the actual pointer number associated with the data, accounting for individual pointers going up and down since the start of the current gesture. - pointerIndex,指针数据索引,常用于getX(int)/getY(int)方法来计算当前事件的坐标值。当它的值小于0,就代表不拦截事件。
英文描述:The index of the pointer (for use with {@link #getX(int)} et al.), value -1 if there is no data available for that pointer identifier.
已经有各路大神对这些概念进行过深度剖析,这里给大家推荐一篇博客安卓自定义View进阶 - MotionEvent详解,有兴趣的可以阅读一下。
-
场景3处理规则
场景3通常是多个滑动冲突嵌套的情形,这种情况我们只能将复杂的滑动冲突拆分成多个简单的滑动冲突,然后再针对每个滑动冲突寻找相对应的解决方案。
三、滑动冲突的解决方法
不管多复杂的滑动冲突,它们之间的区别仅仅是滑动的规则不同而已,所以我们需要找到一种不依赖具体规则的通用解决方法。针对场景1的滑动冲突,我们直接给出两种解决滑动冲突的方法:外部拦截法和内部拦截法。
-
外部拦截法
这种方法比较符合事件的分发机制,因为事件的传递总是由外向内进行的,即事件总是先传递给父容器,再由父容器分发给子元素。我们可以直接从父容器入手,重写它的onInterceptTouchEvent方法,在内部做相应的拦截,即如果父容器需要这个事件就拦截,onInterceptTouchEvent返回true,否则不拦截事件。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
float x = ev.getX();
float y = ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
if (/*需要当前点击事件*/) {
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
return intercept;
}
这是外部拦截法的典型逻辑,我们只需要判断需要当前点击事件这个条件是否成立,其他不需要修改也不能修改。代码中有几点需要注意:
- ACTION_DOWN事件不能被父容器拦截,因为父容器一旦拦截了ACTION_DOWN,那么这个事件序列中的后续的事件都将交给它处理,事件就没法传递给子元素了
- ACTION_MOVE事件根据需求来判断是否需要被父容器拦截,需要拦截返回true,否则返回false
- ACTION_UP事件必须要返回false,因为该事件本身没有太多意义
考虑一种情况,假设事件交给子元素处理,如果父容器在ACTION_UP时返回true,那么ACTION_UP事件就无法传递给子元素,这个时候子元素的onClick事件就无法触发。父容器一旦拦截任何一个事件,那么后续的事件都会交给它来处理,ACTION_UP作为最后一个事件也必定可以传递给父容器,即使onInterceptTouchEvent方法在ACTION_UP时返回了false。
-
内部拦截法
采用内部拦截法,意味着所有事件都要交给子元素处理,如果子元素需要这些事件就直接消耗,否则就交给父容器处理。我们需要重写子元素的dispatchTouchEvent方法,并且需要配合requestDisallowInterceptTouchEvent方法才能正常工作,伪代码如下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
float x = ev.getX();
float y = ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
//不允许父容器拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if (true/*父容器需要点击事件*/) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
}
return super.dispatchTouchEvent(ev);
}
除了子元素需要处理之外,父容器也要默认拦截除了ACTION_DOWN以外的其他事件,这样当子元素调用getParent().requestDisallowInterceptTouchEvent(false)时,父容器才能继续拦截所需事件。父容器之所以不能拦截ACTION_DOWN事件,是因为一旦拦截ACTION_DOWN事件,那么后续事件都会默认交给父容器处理,这样内部拦截就无法起作用了。我们开始重写父容器的onInterceptTouchEvent方法:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if (action == MotionEvent.ACTION_DOWN) {
//不能拦截ACTION_DOWN事件
return false;
} else {
//默认拦截其他事件
return true;
}
}
参考
《Android开发艺术探索》