一. 介绍
在Android 开发中,一个页面可以有很多View,这些View 可能会重叠,所以当我们点击某一个区域可以有多个View 可以响应时,这个事件该由谁来处理,这就涉及到事件的分发机制。
二. View 的结构
View 是一个树形结构,比如下图
image.png
这个页面的结构如下面的树形结构所示
image.png
可以看到在结构图中还有PhoneWindow 和DecorView ,这两个并没有在XML 布局文件中声明,那么他们是什么呢。
Window:
Window类是一个抽象类,它定义了顶级窗体样式和行为。一个Window实例应作为顶级View添加到WindowManager中。它提供标准的UI规则,例如背景、标题、默认关键过程等。每一个Activity 组件都有一个关联的Window对象,用来描述一个应用程序窗口。(window 的知识点也有很多,可以展开成一章,这里先简单介绍)
PhoneWindow:
上面提到Window 是一个抽象类,而PhoneWindow 则是Window 的唯一实现类。
DecorView:
DecorView 是PhoneWindow 的一个内部类,继承了FrameLayout,是一个Activity 的顶级View,内部会包含一个竖直方向的LinearLayout,这个LinearLayout有上下两部分,分为titlebar和contentParent两个子元素,contentParent的id是content,而我们自定义的Activity的布局就是contentParent里面的一个子元素。View层的所有事件都要先经过DecorView后才传递给我们的View。
三. 事件的分发流程
前面我们了解到了我们的View是树形结构的,基于这样的结构,我们的事件可以进行有序的分发。事件收集之后最先传递给 Activity, 然后依次向下传递,大致如下:
Activity -> PhoneWindow -> DecorView -> ViewGroup -> ... -> View
这样的事件分发机制逻辑非常清晰,如果最后分发到View,如果没有任何View消费掉事件,那么这个事件会按照反方向回传,最终传回给Activity,如果最后 Activity 也没有处理,本次事件才会被抛弃:
Activity <- PhoneWindow <- DecorView <- ViewGroup <- ... <- View
在事件分发过程中,有三个方法非常重要
类型 | 相关方法 | Activity | ViewGroup | View |
---|---|---|---|---|
事件分发 | dispatchTouchEvent | √ | √ | √ |
事件拦截 | onInterceptTouchEvent | X | √ | X |
事件消费 | onTouchEvent | √ | √ | √ |
√ 代表有这个方法,X 代表没这个方法。
这个三个方法均有一个 boolean(布尔) 类型的返回值,通过返回 true 和 false 来控制事件传递的流程。
从上表可以看到 Activity 和 View 都是没有事件拦截的,这是因为:
-
Activity 作为原始的事件分发者,如果 Activity 拦截了事件会导致整个屏幕都无法响应事件,这肯定不是我们想要的效果。
-
View最为事件传递的最末端,要么消费掉事件,要么不处理进行回传,根本没必要进行事件拦截。
在一个ViewGroup 中,事件的分发可以用下面的伪代码来表示
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean result = false; // 默认状态为没有消费过
if (!onInterceptTouchEvent(ev)) { // 如果没有拦截交给子View
result = child.dispatchTouchEvent(ev);
}
if (!result) { // 如果事件没有被消费,询问自身onTouchEvent
result = onTouchEvent(ev);
}
return result;
}
在View 中,没有onInterceptTouchEvent 方法,在dispatchTouchEvent 中调用onTouchEvent 来决定是否消费事件。当一个View需要处理事件时,如果它设置了OnTouchListener,那么onTouch方法会被调用,如果onTouch返回false,则当前View的onTouchEvent方法会被调用,返回true则不会被调用,同时,在onTouchEvent方法中如果设置了OnClickListener,那么他的onClick方法会被调用。由此可见处理事件时的优先级关系:onTouchListener > onTouchEvent > onClickListener
关于事件传递的机制,这里给出一些结论:
-
一个事件系列以down事件开始,中间包含数量不定的move事件,最终以up事件结束。
-
正常情况下,一个事件序列只能由一个View拦截并消耗。
-
某个View拦截了事件后,该事件序列只能由它去处理,并且它的onInterceptTouchEvent不会再被调用。
-
某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件(onTouchEvnet返回false),那么同一事件序列中的其他事件都不会交给他处理,并且事件将重新交由他的父元素去处理,即父元素的onTouchEvent被调用。
-
如果当前正在处理的事件被上层 View 拦截,会收到一个 ACTION_CANCEL,后续事件不会再传递过来。
-
如果View不消耗 ACTION_DOWN 以外的其他事件,那么这个事件将会消失,此时父元素的onTouchEvent并不会被调用,并且当前View可以持续收到后续的事件,最终消失的点击事件会传递给Activity去处理。
-
ViewGroup默认不拦截任何事件。
-
View没有onInterceptTouchEvent方法,一旦事件传递给它,它的onTouchEvent方法会被调用。
-
View的onTouchEvent默认消耗事件,除非他是不可点击的(clickable和longClickable同时为false)。
-
View的enable不影响onTouchEvent的默认返回值。
-
onClick会发生的前提是当前View是可点击的,并且收到了down和up事件。
-
事件传递过程总是由外向内的,即事件总是先传递给父元素,然后由父元素分发给子View,通过requestDisallowInterceptTouchEvent方法可以在子元素中干预父元素的分发过程,但是ACTION_DOWN事件除外。
四. 滑动事件冲突
- 常见场景
-
外部滑动和内部滑动方向不一致;
-
外部滑动方向和内部滑动方向一致;
上面两种情况的嵌套。
- 滑动冲突的处理规则
对于场景一,处理的规则是:当用户左右(上下)滑动时,需要让外部的View拦截点击事件,当用户上下(左右)滑动的时候,需要让内部的View拦截点击事件。根据滑动的方向判断谁来拦截事件。
对于场景二,由于滑动方向一致,这时候只能在业务上找到突破点,根据业务需求,规定什么时候让外部View拦截事件,什么时候由内部View拦截事件。
场景三的情况相对比较复杂,同样根据需求在业务上找到突破点。
- 解决方式
1)外部拦截,指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,否则就不拦截
public boolean onInterceptTouchEvent (MotionEvent event){
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
if (父容器拦截条件) {
intercepted = true;
} else {
intercepted = flase;
}
break;
}
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default : break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
2)内部拦截,指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗,否则就交由父容器进行处理。这种方法与Android事件分发机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作
public boolean dispatchTouchEvent ( MotionEvent event ) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction) {
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器拦截条件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default : break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
除了子元素需要做处理外,父元素也要默认拦截除了ACTION_DOWN 以外的其他事件,这样当子元素调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。因此,父元素要做以下修改:
public boolean onInterceptTouchEvent (MotionEvent event) {
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
网友评论