Android作为一门前端技术,界面与交互占了很大的比重,既然有交互,就一定有点击和滑动事件,有事件就一定会有冲突。因为屏幕上不可能只有一个单一的控件,一定是多个控件层叠在一起。很多小伙伴对滑动冲突不太了解,笔者在做Android的前几年也一直搞不清楚,最近狠下心来决定好好的研究一下源码,从根本上解决Android滑动冲突的问题,在学习的过程中,写下这篇博客,供更多的小伙伴参考。
本文目录:
写在前面:
本文大篇幅都是在分析源码,可能会比较枯燥,建议稍稍有些Android基础的小伙伴观看。
- 什么是事件冲突呢?
当事件只有一个,但是有多个对象想要处理,处理事件的那个对象并不是我们想要的那个对象。这个时候我们认为就是发生了事件冲突。
- 在我们手指触碰手机屏幕的过程中,会有哪些事件呢?
单点触碰时,当我们用手指在手机屏幕上按下的时候,就会出现一个ACTION_DOWN事件。 当我们手指在屏幕上滑动的时候,会有一系列的ACTION_MOVE事件。当我们手指抬起的时候,出现一个ACTION_UP事件。另外还有一个ACTION_CANCEL事件,当事件被上层控件拦截时触发。
那么问题来了,这些事件都是怎么传递的? 本文就是通过看源码来分析ACTION_DOWN事件和ACTION_MOVE事件的传递和处理过程,从而从根本上解决滑动冲突这个疑难问题。
在后面看源码的时候,我们可以知道的是,只有ACTION_DOWN事件才会被分发,而ACTION_MOVE事件是不会被分发的。先记住这个结论。 另外还需要记住的结论是:只有继承自ViewGroup的自定义View会去分发事件,继承自View的自定义View只能处理事件。
一、ACTION_DOWN事件是如何传递的
如下图所示:
当手机点击屏幕时,首先响应的是Activity的dispatchTouchEvent(),下图从代码的角度分析了这一流程:
// Activity的 dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
// 这里调用了getWindow()的superDispatchTouchEvent(ev)
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
====> getWindow:
// 其中的window,是Window类的实现,Android中Window类的实现只有一个 : PhoneWindow
private Window mWindow;
public Window getWindow() {
return mWindow;
}
====> PhoneWindow:
// 来看PhoneWindow中,superDispatchTouchEvent
private DecorView mDecor;
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
// 这里的mDecor是一个DecorView
return mDecor.superDispatchTouchEvent(event);
}
====> DecorView:
public boolean superDispatchTouchEvent(MotionEvent event) {
// 这里调用的是父类的dispatchTouchEvent DecorView继承FrameLayout ,
// 因此,最终调用的是ViewGroup的dispatchTouchEvent
return super.dispatchTouchEvent(event);
}
复制代码
看懂了上面一系列的分析,就可以知道,事件的分发,真正走的是ViewGroup的dispatchTouchEvent方法。 那么我们下面就来到ViewGroup的dispatchTouchEvent方法中:
关键代码:根据intercepted来判断是否要拦截事件。
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
// 如果是ACTION_DOWN事件,先把之前的这些状态清零
cancelAndClearTouchTargets(ev);
resetTouchState(); // 清除了mGroupFlags的值 disallowIntercept为false
}
// Check for interception.
// 在这里判断 事件是否要拦截
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// 第一次进来 disallowIntercept = false
if (!disallowIntercept) {
// 这里肯定会走
// 是否拦截 根据onInterceptTouchEvent的返回值决定
// true 表示拦截 false表示不拦截
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
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;
}
}
/**
* Resets all touch state in preparation for a new cycle.
*/
private void resetTouchState() {
clearTouchTargets();
resetCancelNextUpFlag(this);
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
mNestedScrollAxes = SCROLL_AXIS_NONE;
}
复制代码
由上面的代码可以看出,ViewGroup中down事件是否要拦截,还是要根据onInterceptTouchEvent的返回值决定。 那么onInterceptTouchEvent返回的true或false,接下来的代码流程有什么不同呢?
1. 返回true的情况
如果返回true,说明ViewGroup要拦截这个事件,拦截事件的代码流程如下图:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...
// 此时mFirstTouchTarget还未赋过值,为null
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
// 肯定会走这里
// dispatchTransformedTouchEvent 是否处理事件
// 第三个参数为空,这个参数是childView
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
}
...
}
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
...
if (child == null) {
// 这里调用的是View的dispatchTouchEvent
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}
// 调用child的dispatchTouchEvent 看child是View还是ViewGroup
// 如果是View 就走View的dispatchTouchEvent
// 如果是ViewGroup 就走ViewGroup的dispatchTouchEvent
handled = child.dispatchTouchEvent(transformedEvent);
}
...
}
复制代码
拦截事件,意思就是你是最后一个,必须选择事件是否处理。第一次进入dispatchTransformedTouchEvent时,会去调用View的dispatchTouchEvent。
View的dispatchTouchEvent会去处理事件,前面说过,继承自View的自定义View,只负责处理事件,不分发事件。
2. 返回false的情况
如果返回false,说明ViewGroup不打算拦截事件,也就是要把事件往下分发。那么intercepted=false,代码在dispatchTouchEvent中会进入到下面的代码中:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...
// 不拦截事件 走这里
if (!canceled && !intercepted) {
View childWithAccessibilityFocus = ev.isTargetAccessibilityFocus()
? findChildWithAccessibilityFocus() : null;
// 只有ACTION_DOWN事件,才会进行事件的分发
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
...
final View[] children = mChildren;
// 倒叙取出 ViewGroup会轮询每个子View 是否要处理这个事件
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
// 取出这个ViewGroup中的各个child
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// child是否可见(不可见要判断View是否设置了动画)
if (!child.canReceivePointerEvents()
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
...
resetCancelNextUpFlag(child);
// 是否分发事件 第三个参数传入 child 在这里把事件传递给子View
// 会调用 handled = child.dispatchTouchEvent(transformedEvent);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// 如果为true 说明child要处理这个事件
...
// 所以这句代码执行完后, target.next == null ; mFirstTouchTarget!=null;
// newTouchTarget = mFirstTouchTarget = target;
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;// 跳出循环,有一个子View处理了,就不再向下轮询了
}
...
}
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
...
if (child == null) {
// 这里调用的是View的dispatchTouchEvent
handled = super.dispatchTouchEvent(transformedEvent);
} else {
...
// 调用child的dispatchTouchEvent 看child是View还是ViewGroup 在这里把事件分发给了子View
handled = child.dispatchTouchEvent(transformedEvent);
}
...
}
复制代码
此时得到的结论是:
1.如果不拦截事件,最终调用的是child.dispatchTouchEvent(transformedEvent);也就是把事件分发下去了,此时要看child是View还是ViewGroup。
2. 只有ACTION_DOWN事件才会进行事件的分发。
3. 得到了下面几个值:
target.next == null ;
mFirstTouchTarget!=null;
newTouchTarget = mFirstTouchTarget = target;
alreadyDispatchedToNewTouchTarget = true;
复制代码
此时代码会继续往下走:
(这里要注意一下,for循环轮询子View要不要处理这个事件,如果所有的子View都不处理的话,这几个值都不会改变,那么代码继续往下走,走的就是拦截事件的流程了。(分发给你你不要,和直接拦截不分发给你是一样的。)也就是说,此时事件ViewGroup要拦截掉了,事件回到了上层的ViewGroup手中了。)
代码走到这里就结束了,父View不处理该事件,子View已经处理过了。
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
// 子View已经处理了 父View就不处理了
handled = true;
}
复制代码
二、ACTION_MOVE事件是如何传递的
ACTION_DOWN事件处理完成后,我们就可以知道,是哪个View处理事件了。ACTION_DOWN事件结束后,ACTION_MOVE事件来了,ACTION_MOVE事件虽然不能分发,但依然要从最上层往下传递,走dispatchTouchEvent方法。最上层的ViewGroup直接找到要处理这个事件的View,直接把事件给它。
MOVE事件仍然是从ViewGroup的dispatchTouchEvent出发,依次向下调用,由于参数的值不同,所以代码流程和ACTION_DOWN事件的代码流程有点区别。如下图所示:
如图中所示: 走到最后,dispatchTransformedTouchEvent()时,会调用child.dispatchTouchEvent(transformedEvent); 此时如果child是ViewGroup,会以同样的流程再走一遍代码,直到child为View,调用View的dispatchTouchEvent处理事件。
三、解决滑动冲突的小实战
前面铺垫了那么多,这里终于来到了实战。处理事件冲突,这里的案例是一个ViewPager包裹了一个ListView,ListView负责上下滑动,ViewPager负责左右滑动。
处理事件冲突往往有两种方法:
- 内部拦截法 (子View处理事件)
- 外部拦截法 (父容器处理事件)
1. 内部拦截法
内部拦截法,就是重写子View的dispatchTouchEvent方法,在其中添加特殊处理的代码:
// 内部拦截法:子view处理事件冲突
private int mLastX, mLastY;
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
// 按下时,事件交给自己
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 左右滑动时,事件交给父控件
if (Math.abs(deltaX) > Math.abs(deltaY)) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
复制代码
requestDisallowInterceptTouchEvent()是ViewGroup中改变disallowIntercept值的方法:代码如下
如果传入true:
mGroupFlags |= FLAG_DISALLOW_INTERCEPT & FLAG_DISALLOW_INTERCEPT != 0 为true
复制代码
如果传入false
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT & FLAG_DISALLOW_INTERCEPT != 0 为false
复制代码
这个方法是通过改变mGroupFlags的值,改变了disallowIntercept的值。
当disallowIntercept = true 时,查看源码可以知道,intercepted = false;也就是父容器不会去拦截事件,事件交给子View处理。
当disallowIntercept = false时,intercepted = onInterceptTouchEvent(ev),是否拦截由onInterceptTouchEvent(ev)的返回值决定。
具体源码中对这里的描述在ViewGroup的dispatchTouchEvent方法中:
但是此时,仍然无法左右滑动,这是为什么呢?查看源码发现,源码中在ViewGroup的dispatchTouchEvent方法中,判断是否拦截事件的之前有一段代码如下所示:
红框中的代码,ACTION_DOWN事件时,清除了mGroupFlags的值,使disallowIntercept=false,如下图所示:
因此是否拦截事件由父容器的onInterceptTouchEvent(ev)的返回值决定,所以需要在父容器中重写onInterceptTouchEvent方法:在ACTION_DOWN事件时,手动返回false,不拦截此事件,交给子View去处理。
public boolean onInterceptTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN){
// 在ACTION_DOWN事件时
super.onInterceptTouchEvent(event);
return false;
}
return true;
}
复制代码
再次运行会发现,我们想要的效果已经达到了。
2. 外部拦截法
外部拦截法,就是父容器处理事件,根据重写父容器的onInterceptTouchEvent来手动的控制父容器是否要拦截当前的事件。
private int mLastX, mLastY;
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
mLastX = (int) event.getX();
mLastY = (int) event.getY();
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// 左右滑动时,我要拦截事件
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return true;
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
// 其他的情况,直接调用super.onInterceptTouchEvent(event); 安卓源码中已经做过处理
return super.onInterceptTouchEvent(event);
}
复制代码
到这里,Android中事件冲突的问题,在源码中已经分析清楚了,最近在重新学习自定义View,后续会更新一系列的文章,会根据自己的理解,把所学到的东西分享出来,供更多的小伙伴参考。