滑动冲突的场景
滑动冲突常发生于两个可滑动的控件发生嵌套的情况下。比如RecyclerView嵌套ListView,RecyclerView嵌套ScrollView,ViewPager嵌套RecyclerView等。ViewPager之所以没有滑动冲突是因为它本身就已经帮我们解决掉了。但其它没帮我们处理的情况就需要我们自己写代码去处理。
典型的,根据两个控件的滑动方向,可以将滑动冲突分成两类:一个是不同方向的滑动冲突,如外层控件垂直滑动,内层控件水平滑动。另一个就是同方向的滑动冲突,如内外两层控件都是垂直滑动。
下面举一个不同方向滑动冲突的例子。父容器ScrollViewParent,嵌套HorizontalScrollViewChild,ScrollViewParent可垂直滑动,HorizontalScrollViewChild可水平滑动。
当我们手指放在HorizontalScrollViewChild的区域内并竖直滑动时,我们发现是可以滚动外层的ScrollViewParent的。说明ScrollView本身是解决了部分的滑动冲突的,否则HorizontalScrollViewChild如果消费了MOVE事件,ScrollViewParent就消费不了了,也就无法竖直滑动。观察日志:
日志最终我们可以看到,在绿色处,HorizontalScrollViewChild是有消费MOVE事件的,那之前不是讲错了吗?既然HorizontalScrollViewChild有消费MOVE事件,为啥ScrollViewParent还能滑动呢?因为在刚开始滑动的时候,滑动的距离还太小,因此ScrollViewParent的onInterceptTouchEvent还没有拦截这个事件,所以HorizontalScrollViewChild可以消费到MOVE事件。但后面一旦垂直滑动了一定距离,MOVE事件就会直接被ScrollViewParent消费掉,从而实现竖直滑动。
我们可以看ScrollViewParent的源码佐证一下:
final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {//关键
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
mNestedYOffset = 0;
if (mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
可以看到第3行,只有当yDiff大于一定滑动距离即mTouchSlop时,才会被认定为在垂直方向上滑动,将mIsBeingDragged设置为true。若我们水平滑动HorizontalScrollViewChild,可以断定,因为yDiff是没有超过mTouchSlop的,所以HorizontalScrollViewChild就可以正常滑动了。观察日志:
可以看到,水平滑动HorizontalScrollViewChild时,ScrollViewParent没有在onInterceptTouchEvent拦截MOVE事件,MOVE事件得以顺利被HorizontalScrollViewChild消费,实现水平滑动。
那看样子,两个ScrollView并不冲突呀,他们都已经写好内部逻辑了。其实不然。假设我们现在手指放在HorizontalScrollViewChild区域中,滑斜上方45角度向左/向右滑动,这时候我们就会发现,有时候我们滑动的是HorizontalScrollViewChild,有时候我们滑动的却是ScrollViewParent,这就是典型的不同方向上的滑动冲突。
通用解决方案
外部拦截法
外部拦截法,指的是从外部容器入手,去决定是否要去拦截事件,若拦截掉,子View就没法消费了。现在为了处理斜方向的滑动冲突,我们可以简单地做一个逻辑:当在竖直方向滑动超过15像素时,我们就认为是滑动外部容器ScrollViewParent。代码如下:
public class ScrollViewParent extends ScrollView {
public ScrollViewParent(Context context) {
super(context);
}
public ScrollViewParent(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ScrollViewParent(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private float downX;
private float downY;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "ScrollViewParent dispatchTouchEvent");
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = ev.getX();
downY = ev.getY();
}
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "ScrollViewParent onInterceptTouchEvent. deltaY:" + (ev.getY() - downY));
if (ev.getAction() == MotionEvent.ACTION_MOVE) {
return Math.abs(ev.getY() - downY) > 15;//move事件的拦截逻辑
}
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "ScrollViewParent onTouchEvent");
return super.onTouchEvent(ev);
}
}
观察日志,可以看到此时ScrollViewParent会拦截MOVE事件,直接分发给自己的onTouchEvent,后续的MOVE事件也会直接到onTouchEvent事件去。HorizontalScrollViewChild将收不到MOVE事件。实现效果上也符合我们的预期,当我们斜方向滑动时,滑动的大概率(除非dy足够小)都是外部容器ScrollViewParent了。
当然,这其实是我们假定的一个处理冲突的逻辑,真实的产品逻辑需要根据业务情况去调整。比如当竖直滑动速度超过xx时,滑动外部容器;或者当HorizontalScrollViewChild内部某个View可见时,滑动外部容器,都有可能,但万变不离其宗,最终都是通过改变事件分发的路径去实现。
内部拦截法
其实我没太看懂。。。。
“内部拦截法”跟“外部拦截法”相反,是从内部容器出发去解决冲突。当子类不想让其父类/祖先ViewGroup.onInterceptTouchEvent(MotionEvent)方法时,可以调用requestDisallowInterceptTouchEvent(),从而保证父类/祖先无法通过ViewGroup.onInterceptTouchEvent(MotionEvent)方法对事件进行拦截。且一旦设置了这个flag,那么这次事件序列中后续的所有事件,都不会经过父类/祖先的onInterceptTouchEvent(MotionEvent)方法了。直到下次触摸发生,才会清楚掉这个flag。
我们可以直接看源码,看看这是怎么起作用的。requestDisallowInterceptTouchEvent实现:
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
// We're already in this state, assume our ancestors are too
return;
}
if (disallowIntercept) {//更改标记位
mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
} else {
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
// Pass it up to our parent
if (mParent != null) {
mParent.requestDisallowInterceptTouchEvent(disallowIntercept);//递归访问父节点执行该方法
}
}
在View的dispatchTouchEvent方法中,对标记位进行了判断:
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {//当disallowIntercept结果为true时,就不会走onInterceptTouchEvent()方法了
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
现在我们仍是以上面的ScrollViewParent和HorizontalScrollViewChild举例去解决滑动冲突。“内部”要求我们以内部容器的视角去考虑冲突,那么就假定当在水平方向滑动超过15像素时,滑动内部容器HorizontalScrollViewChild。这时候就需要这样编码:
public class HorizontalScrollViewChild extends HorizontalScrollView {
public HorizontalScrollViewChild(Context context) {
super(context);
}
public HorizontalScrollViewChild(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HorizontalScrollViewChild(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
private float downX;
private float downY;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "HorizontalScrollViewChild dispatchTouchEvent. deltaX:" + Math.abs(ev.getX() - downX));
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = ev.getX();
downY = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (Math.abs(ev.getX() - downX) > 15) {
//关键
getParent().requestDisallowInterceptTouchEvent(true);
} else {
getParent().requestDisallowInterceptTouchEvent(false);
}
}
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "HorizontalScrollViewChild onInterceptTouchEvent");
return super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
Log.i("touch_event_test", "HorizontalScrollViewChild onTouchEvent");
return super.onTouchEvent(ev);
}
}
一旦标记位被设置为true,后续事件序列中的所有事件都不会调用了父类的onInterceptTouchEvent了。因此我们需要视情况将限制放开,如30行的写法就将flag重新设为false。另外还要注意,因为子View是DOWN事件的消费点,那么MOVE事件是不会经过子View的onInterceptTouchEvent方法的,所以在dispatchTouchEvent或者onTouchEvent中都可以设置标记位,唯独不能放到onInterceptTouchEvent
小结
虽然上面只举了不同方向滑动冲突的例子,但不同方向冲突与同方向冲突,其最终处理的思路都是一样的,要么改变事件分发的路径,要么设置FLAG_DISALLOW_INTERCEPT标记位。它们在解决上的不同之处,就只在于“条件判断” 。
对于不同方向的冲突,我们给的条件判断可以是:当DX>X时,水平滑动、当DY>Y时,竖直滑动。
对于同方向的冲突,我们给的条件判断可以是:当滑动速度大于X时,滑动外部View,反之滑动内部View;当内部View已经滑到底了,才滑动外部View;当内部View中的某个子View不在屏幕中时,滑动外部View。
一般这些条件判断也都是基于各自的业务出发去进行选择,但只要我们知道通用的处理思路,问题就都可以迎刃而解了。至此,对滑动冲突的通用处理方法(“内部拦截法”与“外部拦截法”)就讲完了。下面进行个小结:
“外部拦截法”所使用的原理是运用事件分发机制,去改变事件分发的路径,拦截内部容器的事件。
“内部拦截法”使用的是requestDisallowInterceptTouchEvent()方法设置FLAG,不让父容器/祖先容器用onInterceptTouchEvent拦截方法。
使用“内部拦截法”还是“外部拦截法”,首先需要去看实际业务需要我们怎么做,是从“内部”实现比较方便,还是从“外部”实现比较方便。
相较于“外部拦截法”,“内部拦截法”并没有减少事件分发的层级,因此看起来可能会更加复杂一些。并且也需要注意requestDisallowInterceptTouchEvent方法具体在哪个方法中使用。若两个方法都能实现最终的效果,建议优先使用“外部拦截法”。