1 概述
当Android系统捕获到触摸事件后,如何准确地传递给真正需要这个事件的View呢?Android系统给我们提供了一整套完善的事件分发机制,来帮助开发者完成准确的事件分发。
2 预备知识
2.1 触摸事件(MotionEvent)
一次手指触碰屏幕的行为所产生的事件序列中典型的事件类型有如下几种:
ACTION_DOWN ----- 手指刚接触屏幕
ACTION_MOVE ----- 手指在屏幕上移动
ACTION_UP ----- 手指从屏幕上松开的一瞬间
正常情况下,一次手指触碰屏幕的行为会触发的触摸事件序列包含如下几种情况:
点击屏幕后立即松开,事件顺序为 ACTION_DOWN -> ACTION_UP
点击屏幕后滑动一会再松开,事件顺序为 ACTION_DOWN -> ACTION_MOVE ->...-> ACTION_MOVE -> ACTION_UP
对于多指触摸的场景则在ACTION_DOWN 和 ACTION_UP之间会成对出现ACTION_POINTER_DOWN 和 ACTION_POINTER_UP
上面两种情况是典型的事件序列。通过MotionEvent对象我们可以得到点击事件发生的x和y坐标,MotionEvent提供了两组方法:getX/getY返回的是相对于当前View左上角的x和y坐标,getRawX/getRawY返回的是相对于手机屏幕左上角的x和y坐标。
2.2 FLAG_DISALLOW_INTERCEPT 标记
当View被设置了该标记(调用requestDisallowInterceptTouchEvent方法)时该View将不再拦截触摸事件,直到接收到ACTION_DOWN类型触摸事件(触摸事件序列的第一个触摸事件)时会清空该标记。
3 触摸事件序列分发和处理机制
当一个触摸事件序列产生后,它的分发顺序如下:
**Activity -> Window -> DecorView -> ... -> 目标View **
然后在不被拦截的情况下,触摸事件会被传递到触摸位置对应的目标View,分发完成后就要处理触摸事件了,处理顺序是从最底层View向Activity进行的。
3.1 Activity对触摸事件的分发和处理过程
当一个触摸事件产生时,触摸事件最先分发给Activity,接着调用Activity的dispatchTouchEvent来进行触摸事件的分发:
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
首先事件分发给Activity所附属的Window,如果返回true,即消耗了触摸事件,整个触摸事件的分发和处理过程就结束了,返回false意味着事件没有View消耗该触摸事件,那么Activity的onTouchEvent()方法就会被调用:
public boolean onTouchEvent(MotionEvent event) {
if (mWindow.shouldCloseOnTouch(this, event)) {
finish();
return true;
}
return false;
}
可以看到Window的shouldCloseOnTouch方法会被调用:
public boolean shouldCloseOnTouch(Context context, MotionEvent event) {
final boolean isOutside =
event.getAction() == MotionEvent.ACTION_DOWN && isOutOfBounds(context, event)
|| event.getAction() == MotionEvent.ACTION_OUTSIDE;
if (mCloseOnTouchOutside && peekDecorView() != null && isOutside) {
return true;
}
return false;
}
可以看到当触摸事件在Activity的范围外并且mCloseOnTouchOutside为true时该触摸事件被消耗,从而在onTouchEvent方法中会finish该Activity,mCloseOnTouchOutside可以通过Activity的setFinishOnTouchOutside方法设置的,当将Activity设置为Dialog的样式时,点击Activity的之外的屏幕区域自动隐藏Activity就可以通过该方法实现(android 14版本该属性默认设置为true)。
3.2 Window对触摸事件的分发过程
Window.superDispatchTouchEvent()是一个抽象方法,通过阅读相关文档和源码可知是Window的唯一实现类是PhoneWindow。查看PhoneWindow的superDispatchTouchEvent方法:
@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
可以看到PhoneWindow将事件直接分发给mDecor处理,DecorView中superDispatchTouchEvent方法源码如下:
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
DecorView继承自FrameLayout,所以mDecor对触摸事件的分发和处理与ViewGroup一样,下面会进行详细讲解。
3.3 ViewGroup对触摸事件的分发和处理过程
根据上图给出我对于ViewGroup触摸事件分发和处理机制的结论:
单指触摸场景(红色布局为父布局,红色、紫色和蓝色布局是红色布局的子布局):
1> 在正常情况下,同一个触摸事件序列中所有的触摸事件只被一个View消耗。
上图中从A点滑动到B点,A点的触摸事件被红色布局消耗,那么从A点到B点所有触摸事件都会分发给红色布局处理
上图中从B点滑动到C点,B点的触摸事件被黄色布局消耗,那么从B点到C点所有触摸事件都会分发给黄色布局处理
2> 某个View一旦开始处理事件,如果它不消耗ACTION_DOWN事件,那么同一事件序列中的其他事件都不会再交给它来处理。
上图中从B点滑动到C点,若B点的触摸事件未被黄色布局消耗,那么从之后的所有触摸事件都不会分发给黄色布局处理
3> 如果View不消耗除ACTION_DOWN以外的其他事件,那么这个触摸事件会消失,并且父元素的onTouchEvent()并不会被调用,
并且当前View可以持续收到后续的事件,最终这些消失的点击事件会传递给Activity处理
上图中从B点滑动到C点,若B点的触摸事件被黄色布局消耗,那么从之后的所有触摸事件都会分发给黄色布局处理,
如果黄色布局不消耗之后的触摸事件,那么红色布局也不会消耗之后的触摸事件。
4> View的onTouchEvent的返回值取决于它是否可点击的(clickable和longClickable属性),如果这两个属性都为false的话,
onTouchEvent就会返回false,其余情况都返回true。View的longClickable属性默认为flase,clickable属性要分情况,比如Button的clickable
属性默认为true,而TextView的clickable属性默认为false。注意,setOnClickListener()会自动将View的clickable属性设为true。
View的enable属性不影响onTouchEvent()的返回值。哪怕一个View是disable状态的,只要它的clickable或者longClickable有一个为true,
那么它的onTouchEvent就会返回true。
5> 如果View的enable属性被设置为false,此时无法响应点击事件,可以通过设置setAllowClickWhenDisabled方法
允许disable状态下响应点击事件,很多应用登陆界面的登陆按钮即是是灰色也响应点击事件,也许就是这样实现的。
多指触摸场景:
1> 红色布局、黄色布局 和 紫色布局是同一层级布局
第一个手指触摸到A点并且消耗掉ACTION_DOWN事件,第二个手指触摸到B点,分发给B点(黄色布局)的是ACTION_DOWN事件
第一个手指触摸到B点并且消耗掉ACTION_DOWN事件,第二个手指触摸到C点,分发给C点(紫色布局)的是ACTION_DOWN事件
2> 红色布局是黄色布局的父布局
第一个手指触摸到A点并且消耗掉ACTION_DOWN事件,第二个手指触摸到B点,分发给B点(红色布局)的是ACTION_POINTER_DOWN事件
下面在源码(ViewGroup的dispatchTouchEvent方法)的层面来讲解触摸事件的分发和处理机制,首先是事件序列第一个事件(ACTION_DOWN类型触摸事件)对于的初始化逻辑:
// 处理ACTION_DOWN类型触摸事件(触摸事件序列的第一个触摸事件)
if (actionMasked == MotionEvent.ACTION_DOWN) {
// 将mFirstTouchTarget链表清空,即mFirstTouchTarget会被置为null
// 清空PFLAG_CANCEL_NEXT_UP_EVENT标记
cancelAndClearTouchTargets(ev);
// 清空FLAG_DISALLOW_INTERCEPT标记
resetTouchState();
}
在这里我们先来了解mFirstTouchTarget成员变量,该变量在事件分发过程中起着很重要的作用,mFirstTouchTarget是TouchTarget类型变量,接下来看一下TouchTarget主要成员变量:
// 消耗触摸事件的View
public View child;
// 多点触摸时每个触摸点都会携带一个pointerId,pointerIdBits包含被View消耗的触摸点的pointerId的组合
public int pointerIdBits;
// 链表中的下一个元素
public TouchTarget next;
mFirstTouchTarget为链表中的第一个元素,对于mFirstTouchTarget有如下结论:
1> 单点触摸:mFirstTouchTarget链表只包含一个TouchTarget对象
2> 多点触摸并且所有触摸点事件被同一个View消耗:mFirstTouchTarget链表只包含一个TouchTarget对象。
3> 多点触摸并且所有触摸点事件被多个View消耗:mFirstTouchTarget成为链表,每一个TouchTarget对象对应一个消耗触摸点事件的View。
继续看dispatchTouchEvent方法中拦截触摸事件相关的源码:
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
// 根据onInterceptTouchEvent的返回结果来决定该View是否拦截触摸事件
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
// 设置FLAG_DISALLOW_INTERCEPT标记时,该View将不会拦截触摸事件
intercepted = false;
}
} else {
// 对于之前的ACTION_DOWN类型触摸事件没有被其子布局消耗并且当前触摸事件不是ACTION_DOWN类型情况,该View将会持续拦截触摸事件
intercepted = true;
}
继续看dispatchTouchEvent方法的几个变量:
// 由于在3.3中的第一段代码中间接的调用了resetCancelNextUpFlag方法,因此这里调用resetCancelNextUpFlag方法返回false
final boolean canceled = resetCancelNextUpFlag(this)
|| actionMasked == MotionEvent.ACTION_CANCEL;
// 当设置FLAG_SPLIT_MOTION_EVENTS标记时(通过调用setMotionEventSplittingEnabled方法设置),则支持触摸事件拆分,即支持多点触控。
// 当API版本大于等于Build.VERSION_CODES.HONEYCOMB(11)时,则默认设置FLAG_SPLIT_MOTION_EVENTS标记。
final boolean split = (mGroupFlags & FLAG_SPLIT_MOTION_EVENTS) != 0;
// 用来标记该触摸事件被View消耗时对应的TouchTarget对象
TouchTarget newTouchTarget = null;
// 用来标记该触摸事件是否被新的View消耗
boolean alreadyDispatchedToNewTouchTarget = false;
接下来是遍历子View寻找ACTION_DOWN或者ACTION_POINTER_DOWN类型触摸事件的对应的子View然后分发和处理该递触摸事件:
// 当触摸事件未被取消并且未被拦截时进入到触摸事件分发和处理的逻辑
if (!canceled && !intercepted) {
// 只针对ACTION_DOWN或者ACTION_POINTER_DOWN类型的触摸事件(鼠标相关的事件触摸事件类型ACTION_HOVER_MOVE不考虑)
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
// 代表多点触摸中第actionIndex个触摸点
final int actionIndex = ev.getActionIndex(); // always 0 for down
// 这里只考虑支持多点触摸的情况,即split为true
// 1 << ev.getPointerId(actionIndex)是对0000 0001左移pointerId位,一般情况pointerId从0开始,每次+1,
// 例如 0对应0000 0001,2对应0000 0100。
final int idBitsToAssign = split ? 1 << ev.getPointerId(actionIndex)
: TouchTarget.ALL_POINTER_IDS;
// Clean up earlier touch targets for this pointer id in case they
// have become out of sync.
removePointersFromTouchTargets(idBitsToAssign);
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
// 下面删除了子View绘制排序的相关源码,即认为customOrder == false,有兴趣的同学可以参照源码
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = i;
final View child = children[i];
// 当子VIew可见性为VISIBLE或者正在执行动画,则canViewReceivePointerEvents方法返回true
// 当子View的范围包含触摸事件,则isTransformedTouchPointInView返回true
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
continue;
}
// getTouchTarget方法用来在mFirstTouchTarget链表中查找包含child的TouchTarget对象
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// 找到了TouchTarget对象newTouchTarget并且其包含的触摸事件和当前触摸事件都在child范围内,
// 则将当前触摸事件的idBitsToAssign添加到newTouchTarget中。
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
// dispatchTransformedTouchEvent方法返回true代表child消耗了当前触摸事件
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// addTouchTarget方法使用child和idBitsToAssign创建一个TouchTarget对象并将其放在mFirstTouchTarget链表的头部,
// 返回值为该TouchTarget对象。
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}
if (newTouchTarget == null && mFirstTouchTarget != null) {
// mFirstTouchTarget != null代表之前的ACTION_DOWN类型触摸事件被某个子View消耗
// newTouchTarget == null代表当前的ACTION_POINTER_DOWN类型触摸事件的未找到消耗其的子View,
// 那么就让mFirstTouchTarget的第一个TouchTarget对象包含的View消耗当前触摸事件
newTouchTarget = mFirstTouchTarget;
while (newTouchTarget.next != null) {
newTouchTarget = newTouchTarget.next;
}
newTouchTarget.pointerIdBits |= idBitsToAssign;
}
}
}
接下来就是分发和处理摸事件的逻辑:
if (mFirstTouchTarget == null) {
// mFirstTouchTarget == null 代表没有子View处理当前的触摸事件,则交给当前View处理
// dispatchTransformedTouchEvent方法的参数child传null代表当前触摸事件交给当前View处理
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// 遍历mFirstTouchTarget链表寻找处理当前触摸事件的TouchTarget对象
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
// alreadyDispatchedToNewTouchTarget为true代表当前触摸事件是ACTION_DOWN
// 或者ACTION_POINTER_DOWN类型并且已经被消耗掉,因此不需要再次被分发和处理
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
// dispatchTransformedTouchEvent方法返回true代表target.child消耗了当前触摸事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
// 如果拦截了当前触摸事件,则移除之前触摸事件对应的TouchTarget对象
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}
滑动冲突
对于Android开发者,相信对滑动冲突大家一定不陌生,下面就来介绍滑动冲突和解决滑动冲突(利用上面介绍的事件传递、处理的机制解决)。
-
常见的滑动冲突场景
常见的滑动冲突的场景可以分为如下三种:
场景1 --- 外部滑动方向和内部滑动方向不一致
场景2 --- 外部滑动方向和内部滑动方向一致
场景3 --- 上面两种情况的嵌套
- 滑动冲突的处理规则
对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的View拦截触摸事件,当用户上下滑动时,需要让内部View拦截触摸事件。
对于场景2,它没有既定的处理规则,因为它要根据具体的业务来制定处理规则,即当处于某种状态下时需要外部View拦截触摸事件,而处于另外一种状态时需要内部View拦截触摸事件。
对于场景3,与场景2相同,必须根据具体业务制定处理规则。 - 滑动冲突的解决方案
对于3种常见的滑动冲突场景,本节将会一一分析各种场景并给出具体的解决方案。无论多复杂的滑动冲突,它们之间的区别仅仅是滑动冲突处理规则不同,所以我们可以抛开滑动冲突处理规则,找到一种不依赖具体的滑动冲突处理规则的通用解决方案,然后根据不同的滑动冲突场景和业务来修改有关滑动冲突处理规则的逻辑即可。
针对滑动冲突,这里给出两种解决滑动冲突的方案:外部拦截法和内部拦截法。
1> 外部拦截法
所谓外部拦截法是指所有的触摸事件都会先经过经过父容器的传递,从而父容器在需要此触摸事件的时候就可以拦截此触摸事件,否者就传递给子View。这样就可以解决滑动冲突的问题,这种方法比较符合触摸事件的传递、处理机制。外部拦截法需要重写父容器的onInterceptTouchEvent方法,在该方法中根据滑动冲突处理规则做相应的拦截即可,这种方法的典型代码如下:
@Override
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 = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercepted = false;
break;
}
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突场景,只需要修改父容器需要当前触摸事件这个滑动冲突处理规则即可,其它均不用修改并且不能修改。这里对上述代码再来解释一下,在onInterceptTouchEvent方法中,首先是ACTION_DOWN类型的触摸事件,父容器必须返回false,即不拦截ACTION_DOWN类型的触摸事件,这是因为一旦父容器拦截了ACTION_DOWN类型的触摸事件,那么后续处于同一个事件序列的ACTION_MOVE和ACTION_UP类型的触摸事件就会直接交给父容器处理,这个时候事件就没法再传递给子元素了;其次是ACTION_MOVE类型的触摸事件,这个类型的触摸事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否者返回false(与滑动冲突处理规则有关);最后是ACTION_UP类型的触摸事件,这里必须返回false,考虑一张情况,假设事件交由子元素处理,如果父容器在ACTION_UP类型的触摸事件时返回了true,就会导致子元素无法接收到ACTION_UP类型的触摸事件,这个时候子元素中的onClick方法就无法触发。
2> 内部拦截法
内部拦截法是指父容器不拦截任何触摸事件,所有的触摸事件都传递给子元素,如果子元素需要此触摸事件就直接消耗掉,否者就交由父容器进行处理,这种方法和Android中的事件传递、处理机制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起来较外部拦截法稍显复杂。这种方法需要重写子元素的dispatchTouchEvent方法和父容器的onInterceptTouchEvent方法,这种方法的典型代码如下:
子元素的dispatchTouchEvent方法
@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 (父容器需要当前触摸事件) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
父容器的onInterceptTouchEvent方法
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int action = event.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
上面的代码是内部拦截法的典型代码,当面对不同的滑动冲突处理规则时只需要修改里面的条件即可,其它的不需要修改而且也不能修改。除了子元素需要做处理以外,父容器也要默认拦截除了ACTION_MOVE类型触摸事件的其他事件,这样子元素调用requestDisallowInterceptTouchEvent(false)方法时,父容器才能继续拦截所需事件。
参考
- 《Android开发艺术探索》
- 《Android群英传》