一. 滑动冲突场景以及产生原因
产生滑动冲突的场景主要有两种:
- 父ViewGroup和子View的滑动方向一致
- 父ViewGroup和子View的滑动方向不一致
那为什么会产生滑动冲突呢,例如在父ViewGroup和子View的滑动方向一致的情况,我需要让两者都可以滑动。在上篇博客中我们分析了事件分发机制,其中提到ViewGroup的onInterceptTouchEvent方法默认情况下是返回false,也就是ViewGroup默认情况下是不会拦截事件的。当ViewGroup接收到事件时,由于不拦截事件,会去寻找能够处理事件的子View。此时,一旦子View处理了DOWN事件,默认情况下接下来同一事件序列的其他事件都交由子View处理,此时可以看到的效果是子View可以滑动,但是父ViewGroup始终滑动不了,此时滑动冲突就出现了。
二. 滑动冲突的解决方式
滑动冲突主要有两种解决方式:外部拦截法和内部拦截法
例如我们使用ViewPager时,往往会结合Fragment,然后Fragment内部为一个ListView。这里ViewPager已经为我们解决了滑动冲突,因此在使用时并不会冲突。试想下,若ViewPager未解决滑动冲突,默认情况下ViewPager的onInterceptTouchEvent方法返回false,由于ListView可以滚动,代表ListView可以处理事件,所以所有事件都被ListView处理了,因此我们看到的效果会是ListView可以在竖直方向上滚动,但是ViewPager在水平方向上无法滑动。
可以重写ViewPager,让ViewPager的onInterceptTouchEvent方法返回默认状态下的false,ViewPager内部是多个ListView。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class MyViewPager extends ViewPager { public MyViewPager(@NonNull Context context) { super(context); }
public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); }
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { return false; }
}
|
运行效果如图
所以ViewPager是如何解决这样的滑动冲突的呢,由此引出外部拦截法。
2.2 外部拦截法
2.2.1 原理
所谓外部拦截法,就是当事件传递到父容器时,通过父容器去判断自己是否需要此事件,若需要则拦截事件,不需要则不拦截事件,将事件传递给子View。 上述MyViewPager和ListView显然产生了滑动冲突,我们来分析下。我们要的效果是在水平方向上滑动时ViewPager可以水平滚动,在竖直方向上滑动时,ListView可以滚动但ViewPager不动,因此我们需要为ViewGroup指定事件处理的条件,于是就有了下面的伪代码。
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_MOVE: if (ViewPager需要此事件) { return true; } break; default: break; } return false; }
|
现在我们来分析下为什么这段代码可以解决滑动冲突。
这边首先要注意一点,外部拦截时在重写ViewGroup的onInterceptTouchEvent方法时,ViewGroup不能拦截DOWN事件和UP事件。因为一旦ViewGroup拦截了DOWN事件,也就是和mFirstTouchTarget始终为空,同一事件序列中的其他事件都不会再往下传递;若ViewGroup拦截了UP事件,则子View就不会触发单击事件,因为子View的单击事件是在UP事件时被触发的。
- 当ViewPager接收到DOWN事件,ViewPager默认不拦截DOWN事件,DOWN事件交由ListView处理,由于ListView可以滚动,即可以消费事件,则ViewPager的mFirstTouchTarget会被赋值,即找到处理事件的子View。然后ViewPager接收到MOVE事件,
- 若此事件是ViewPager不需要,则同样会将事件交由ListView去处理,然后ListView处理事件;
- 若此事件ViewGroup需要,因为DOWN事件被ListView处理,mFirstTouchEventTarget会被赋值,也就会调用onInterceptedTouchEvent,此时由于ViewPager对此事件感兴趣,则onInterceptedTouchEvent方法会返回true,表示ViewPager会拦截事件,此时当前的MOVE事件会消失,变为CANCEL事件,往下传递或者自己处理,同时mFirstTouchTarget被重置为null。
- 当MOVE事件再次来到时,由于mFristTouchTarget为null,所以接下来的事件都交给了ViewPager。
2.2.2 解决方式
这边ViewPager处理事件的条件可以有多种方法,例如水平方向和竖直方向上的滑动速度、水平方向和竖直方向的滑动距离等。这边根据滑动距离判断,当水平方向的滑动距离大于竖直方向的滑动距离,则ViewPager处理事件,反之则将事件传递给ListView。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| public class MyViewPager extends ViewPager { private int mLastX; private int mLastY;
public MyViewPager(@NonNull Context context) { super(context); }
public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) { super(context, attrs); }
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { super.onInterceptTouchEvent(ev);
boolean isIntercepted = false; switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: if (needEvent(ev)) { isIntercepted = true; } break; default: } mLastX = (int) ev.getX(); mLastY = (int) ev.getY();
LogUtils.d(" lastX = " + mLastX + " lastY = " + mLastY); return isIntercepted; }
private boolean needEvent(MotionEvent ev) { return Math.abs(ev.getX() - mLastX) > Math.abs(ev.getY() - mLastY); }
}
|
运行效果:
2.2.3 总结
- 外部拦截法主要是父容器去控制事件的拦截,若事件是父容器需要的,则进行拦截,不需要的则向下传递。
- 父容器不能拦截DOWN事件或者UP事件。
2.2.4 通用模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public boolean onInterceptTouchEvent(MotionEvent ev) { boolean isIntercept = false; switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: isIntercept = false; break; case MotionEvent.ACTION_MOVE: isIntercept = needThisEvent(); break; case MotionEvent.ACTION_UP: isIntercept = false; break; default: break; } return isIntercept; }
|
2.3 内部拦截法
2.3.1 冲突场景
下面讲一种稍微复杂一点的同向滑动冲突。ScrollView内部的内部的LinearLayout存在三个子View,从上到下分别为ImageView、ListView以及TextView。
先上下效果图:
可以看到现在需要的效果是触摸ListView外部的区域,ScrollView的滑动不受限制。当触摸ListView区域时,存在多种情况。当ListView滚动到顶部时(ListView处于初始状态),此时若手指往下滑动,则ScrollView往下滑动;当ListView滚动到底部时,若此时手指往上滑动,则ScrollView往上滑动,其余情况下ListView滚动。
2.3.2 原理
内部拦截法: ViewGroup默认情况下不拦截事件,由子View去控制事件的处理,若子View需要此事件,则自己处理,否则交由父容器处理。
使用内部拦截需要同时重写父ViewGroup的onInterceptTouchEvent和ViewGroup中需要解决冲突的子View的dispatchTouchEvent方法,和上面一样,先上伪代码。
子View伪代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: //禁止父容器拦截事件 getParent().requestDisallowInterceptTouchEvent(true); break; case MotionEvent.ACTION_MOVE: if (当期View不需要此事件) { getParent().requestDisallowInterceptTouchEvent(false); } break; default: break; } return super.dispatchTouchEvent(ev); }
|
ViewGroup 伪代码
1 2 3 4 5 6 7 8 9
| @Override public boolean onInterceptTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: return false; default: return true; } }
|
这边我们结合ScrollView和ListView这个具体实例和流程图进行分析。
首先父容器ScrollView不能拦截DOWN事件,必须将DOWN事件分发至子View,这边子View是 ListView,因为父容器一旦拦截DOWN事件,同一事件序列中的其他事件都不会传递到子View,这点在事件分发源码分析时已经分析了,这里不再赘述。
由于内部拦截是将事件交由子View,由子View去控制事件的处理,所以事件在一开始不能被父ViewGroup直接拦截,由于DOWN事件被子View处理,此时mFristTonchTarget不为null,在默认情况下会去调用onInterceptedTouchEvent,若针对该事件该方法返回true,则事件就会被父容器拦截了,事件显然不会传递到子View,但是我们需要将事件传递到子View,让子View去控制事件的处理。那我们要怎么将事件传递到子View呢?从源码可以看到在调用onInterceptedTouchEvent方法前还有一个判断。
1 2 3 4 5 6 7 8 9 10 11 12 13
| if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) { final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0; if (!disallowIntercept) { intercepted = onInterceptTouchEvent(ev); ev.setAction(action); } else { intercepted = false; } } else { intercepted = true; }
|
从源代码可以看到,会根据disallowIntercept的值判断是否要调用onInterceptTouchEvent这个方法,disallowIntercept默认为false。此时若可以将disallowIntercept的值变为true,就可以绕过onIntercepted方法,将事件传递到子View了,也就是我们需要在MOVE事件到来之前给mGroupFlags设置FLAG_DISALLOW_INTERCEPT标志位,设置好后,若MOVE事件到来,disallowIntercept的值就会变为true,就会绕过onInterceptedTouchEvent方法的执行,将事件传递到子View了,那如何在MOVE事件到来之前给ViewGroup设置这个标志位呢?我们可以在ViewGroup中看到这个方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) { return; }
if (disallowIntercept) { mGroupFlags |= FLAG_DISALLOW_INTERCEPT; } else { mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT; }
if (mParent != null) { mParent.requestDisallowInterceptTouchEvent(disallowIntercept); } }
|
可以看到,若在调用requestDisallowInterceptTouchEvent方法时,参数为true,则mGroupFlags设置了FLAG_DISALLOW_INTERCEPT标志位,也就是disallowIntercept的值就会变为true。至于调用时机,我们只需要在子View接收到DOWN事件时调用该方法即可,此后父ViewGroup会直接将事件传递给处理DOWN事件的子View。
1 2 3 4 5 6 7 8 9 10 11 12
| @Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); break; ... } ... } }
|
若接下来的事件是子View感兴趣的,则直接处理掉,如果子View对事件不感兴趣,则将事件交还给父View,让它去处理。那么问题又来了,如何将子View不需要的事件重新交还给父View处理?此时可能有人会说,在事件分发中,子View处理不了的事件,不是自动会交给父ViewGroup处理吗?我们说的子View处理不了的事件会传递给父ViewGroup处理,这个是针对默认的DOWN事件分发流程,但是在这不是DOWN事件且这里存在人工干预的情况,真的会是这样吗,我们来看看源码。
先明确下当前的情景,子View处理了DOWN事件和部分MOVE事件,此时父ViewGroup的mFirstTouchEvent肯定是不为null的。接下来的MOVE事件子View不需要,也就是子View不做处理,那么子View的dispatchTouchEvent方法会返回false。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| public boolean dispatchTouchEvent(MotionEvent ev) {
...
if (mFirstTouchTarget == null) { handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS); } else { TouchTarget predecessor = null; TouchTarget target = mFirstTouchTarget; while (target != null) { final TouchTarget next = target.next; if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) { handled = true; } else { final boolean cancelChild = resetCancelNextUpFlag(target.child) || intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild, target.child, target.pointerIdBits)) { handled = true; } if (cancelChild) { if (predecessor == null) { mFirstTouchTarget = next; } else { predecessor.next = next; } target.recycle(); target = next; continue; } } predecessor = target; target = next; } }
if (canceled || actionMasked == MotionEvent.ACTION_UP || actionMasked == MotionEvent.ACTION_HOVER_MOVE) { resetTouchState(); } else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) { final int actionIndex = ev.getActionIndex(); final int idBitsToRemove = 1 << ev.getPointerId(actionIndex); removePointersFromTouchTargets(idBitsToRemove); }
if (!handled && mInputEventConsistencyVerifier != null) { mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1); }
return handled; }
|
从源代码可以看到,在这个情景下,ViewGroup的dispatchTouchEvent方法会直接返回false,不处理当前子View不感兴趣的MOVE事件,父ViewGroup的父容器也是这样直接返回false,直到传递给Activity,事件被Activity处理或者消失。并且当再一个MOVE事件来临时,MOVE还是会传递到子View,但是子View对当前MOVE事件不感兴趣,也就是说之后的所有MOVE事件都不会被父ViewGroup处理,这样明显是存在问题的。所以子View在对事件不感兴趣时,要如何事件处理权交给父ViewGroup?我们在子View 通过调用ViewGroup的requestDisallowInterceptTouchEvent方法,禁止父ViewGroup拦截事件,同样也可以在子View对事件不感兴趣时,调用ViewGroup的requestDisallowInterceptTouchEvent方法,允许父容器去拦截事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Override public boolean dispatchTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_MOVE: if (当期View不需要此事件) { getParent().requestDisallowInterceptTouchEvent(false); } break; default: break; } return super.dispatchTouchEvent(ev); }
|
对子View来说,对事件处理的控制逻辑已经完成了,但是对于父ViewGroup来说并没有,必须要重写ViewGroup的onInterceptedTouchEvent方法,让MOVE和UP事件返回true,表示拦截子View不感兴趣的事件,这边父ViewGroup拦截MOVE事件是可以理解的,但是为什么要拦截UP事件呢,因为父ViewGroup只有拦截了UP事件才可以接收单击事件。
2.3.3 具体实现
上述分析了原理,现在来真正解决一下ScrollView和ListView的滑动冲突。其实内部拦截的模板已经在伪代码中体现了。只要实现子View 对事件处理的判断即可。我们需要监听ListView滚动到顶部和底部的状态,当ListView滚动到顶部时且手指触摸方向向下或者ListView滚动到底部且手机触摸方向向上,则将事件交由ScrollView处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
| public class MyListView extends ListView implements AbsListView.OnScrollListener {
private boolean isScrollToTop; private boolean isScrollToBottom;
private int mLastX; private int mLastY;
public MyListView(Context context) { this(context, null); }
public MyListView(Context context, AttributeSet attrs) { this(context, attrs, -1); }
public MyListView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); }
private void init() { setOnScrollListener(this); }
@Override public boolean dispatchTouchEvent(MotionEvent ev) { LogUtils.d("" + Constants.getActionName(ev.getAction())); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: getParent().requestDisallowInterceptTouchEvent(true); mLastX = (int) ev.getX(); mLastY = (int) ev.getY(); break; case MotionEvent.ACTION_MOVE: if (superDispatchMoveEvent(ev)) { getParent().requestDisallowInterceptTouchEvent(false); } break; case MotionEvent.ACTION_UP: LogUtils.d("ACTION_UP"); break; default: break; } return super.dispatchTouchEvent(ev); }
private boolean superDispatchMoveEvent(MotionEvent ev) { boolean canScrollBottom = isScrollToTop && (ev.getY() - mLastY) > 0; boolean canScrollTop = isScrollToBottom && (ev.getY() - mLastY) < 0;
return canScrollBottom || canScrollTop; }
@Override public void onScrollStateChanged(AbsListView view, int scrollState) {
}
@Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { isScrollToBottom = false; isScrollToTop = false;
if (firstVisibleItem == 0) { android.view.View firstVisibleItemView = getChildAt(0); if (firstVisibleItemView != null && firstVisibleItemView.getTop() == 0) { LogUtils.d("##### 滚动到顶部 ######"); isScrollToTop = true; } }
if ((firstVisibleItem + visibleItemCount) == totalItemCount) { View lastVisibleItemView = getChildAt(getChildCount() - 1); if (lastVisibleItemView != null && lastVisibleItemView.getBottom() == getHeight()) { LogUtils.d("##### 滚动到底部 ######"); isScrollToBottom = true; } } }
}
|
至于ScrollView,默认在拖拽状态下会拦截MOVE事件,默认不拦截UP事件,若需要拦截UP事件,可重写ScrollView的onInterceptTouchEvent方法,但不是必须拦截UP事件,若父ViewGroup不需要触发单击事件,则可以不拦截。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public class MyScrollView extends ScrollView { public MyScrollView(Context context) { super(context); }
public MyScrollView(Context context, AttributeSet attrs) { super(context, attrs); }
public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercepted = super.onInterceptTouchEvent(ev); if (ev.getAction() == MotionEvent.ACTION_UP) { intercepted = true; } return intercepted; } }
|
2.3.4 总结
- 内部拦截法是将事件控制权交给子View,若子View需要事件,则对事件进行处理,不需要则将事件传递给父ViewGroup,让父ViewGroup处理。
- 子View通过调用父ViewGroup的requestDisallowInterceptTouchEvent来干预父ViewGroup对事件的拦截状况
- 父ViewGroup不能拦截DOWN事件,至于MOVE或者UP事件的拦截状态要根据具体的情景
好了,到这里两种解决滑动冲突的方式就介绍完了,但要注意的是解决ViewPager与ListView滑动冲突并不是只能用外部拦截,同样可以使用内部拦截实现,第二个情景也是一样。解决方式并不是绝对的,我们要做的是选择最方便实现的方案。