本文将结合具体实例:通过微信聊天页面的交互方式,分析实现方法,进而搞清 OnTouchListener、onTouchEvent、onClick、clickable的关系。
说明 1:本文默认读者已经基本了解事件分发机制,主要是 dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 流程。
说明 2:文中代码以 Android SDK 23 为参考,如果想要亲自调试一下,可以将 compileSdkVersion 设置为 23,并且安装 Nexus 模拟器,模拟器系统版本要与 compileSdkVersion 一致。最好不要使用真机调试,即使系统版本对应,一般也会因为手机厂商对原生系统的改动,导致调试时代码行数不对应。
微信聊天页面示例
交互分析
image- 分析:上图为一张普通的微信聊天页面图,使用经验告诉我们,在当前页面状态下,如果点击聊天文本或者聊天语音,键盘是不会收起的,而点击聊天图片,键盘是会收起的,而且点击任何聊天信息之外的区域(空白区域),键盘都是会收起的。
- 更仔细的观察发现,点击空白区域键盘收起这一操作,并不是一个 click 事件,而是一旦触摸空白区域,键盘就会立马收起。
- 综上所述,当键盘已经弹出后,点击或触摸聊天界面不同区域,会让键盘有不同的动作(保持不变或者收起)
实现方法
- 上述交互需求设计分析如下(假设列表是一个 ListView 控件)
- ListView 应该是被设置了 Touch 事件,而不是 click 事件,因为一旦触摸(TouchDown)就会执行,如果是设置的 click 事件,需要点击(手指抬起)才能够执行。可能的实现方法有两种:
- 继承 ListView 并重写 dispatchTouchEvent(TODO 可以重写其他方法吗?)方法,执行隐藏键盘操作,然后调用 super.dispatchTouchEbent 方法正常分发。
- 这种方案基本可以排除,因为只能统一对键盘进行收起操作,点击聊天文本不需要收起键盘的场景就很难处理,换句话说,此时子 View 是无法控制父 View 设置的这一行为。
- 给 ListView 设置 OnTouchListener,重写 onTouch 方法,在 TouchDown 时隐藏键盘,这样可以实现触摸 ListView 时收起键盘。另外,子 View 可以通过设置自己的点击事件,而达到 ListView 的 OnTouchListener 不被执行的目的,即子 View 设置自己的点击事件,自己单独处理键盘是否需要隐藏,看上去好像子 View 拦截了 ListView 的 onTouch 方法,这种方案是否真的可行呢?子 View 是如何实现让父 View 的 onTouch 事件得不到执行的呢?带着这样的问题,我们来分析一下 View 的事件传递机制。
- 继承 ListView 并重写 dispatchTouchEvent(TODO 可以重写其他方法吗?)方法,执行隐藏键盘操作,然后调用 super.dispatchTouchEbent 方法正常分发。
- ListView 应该是被设置了 Touch 事件,而不是 click 事件,因为一旦触摸(TouchDown)就会执行,如果是设置的 click 事件,需要点击(手指抬起)才能够执行。可能的实现方法有两种:
View 的事件传递
View.dispatchTouchEvent
- 先从 View 的事件分发看起,这里的 View 不包含 ViewGroup,把 View 的 dispatchTouchEvent 关键的代码摘出来,如下所示:
public boolean dispatchTouchEvent(MotionEvent event) {
// 省略部分代码
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) { // 注释 1
result = true;
}
if (!result && onTouchEvent(event)) { // 注释 2
result = true;
}
}
// 省略部分代码
return result;
}
- 代码很明显,先判断是否给当前 View 设置了 OnTouchListener 事件,即 mOnTouchListener 是否为空,当不为空时,调用 mOnTouchListener 的 onTouch 方法(注释 1 处)
- 该判断过程发生在 if 语句中,可见 onTouch 返回值影响到 result 的结果,而 result 又在注释 2 的判断中用到,假设 onTouch 返回了 true,则注释 2 处的 onTouchEvent 方法是得不到执行的,而该方法就是我们熟悉的事件传递机制中的消费事件的方法。
-
综上,我们可以得出结论:OnTouchListener 的优先级是高于 onTouchEvent 的,并且 OnTouchListener 的返回值能够决定是否还会执行 onTouchEvent 方法;
View 的 事件分发机制
ViewGroup.dispatchTouchEvent
- 再来分析一下 ViewGroup 的 dispatchTouchEvent 方法,精简后的代码如下:
public boolean dispatchTouchEvent(MotionEvent ev) {
// 省略代码 ...
boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
// 当为 ACTION_DOWN 时,说明是一个事件序列的开始,会调用 resetTouchState 方法重置状态
// 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.
cancelAndClearTouchTargets(ev);
resetTouchState();
}
// mFirstTouchTarget 是用来记录接收该事件的子 View 的,当为 null 时,说明还没有子 View 接收该事件序列,不为空时,说明已经有子 View 接收了该事件,事件序列的其他事件就可以直接传给该 View。
// 这一段代码主要是检查要不要对事件进行拦截:onInterceptTouchEvent
// Check for interception.
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 {
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;
}
// 省略代码 ...
TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
if (!canceled && !intercepted) {
// 省略代码 ...
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
// 省略代码 ...
final int childrenCount = mChildrenCount;
if (newTouchTarget == null && childrenCount != 0) {
final float x = ev.getX(actionIndex);
final float y = ev.getY(actionIndex);
// 下面会循环遍历子 View,找到可以接收事件的那个
// Find a child that can receive the event.
// Scan children from front to back.
final ArrayList<View> preorderedList = buildTouchDispatchChildList();
final boolean customOrder = preorderedList == null
&& isChildrenDrawingOrderEnabled();
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// 省略代码 ...
newTouchTarget = getTouchTarget(child);
// 省略代码 ...
// dispatchTransformedTouchEvent 可以看成将事件传递给参数 child,即调用了 child 的 dispatchTouchEvent 方法
// 如果child 是 ViewGroup,这个过程相当于递归调用;如果 child 是 View,则调用我们上一小节分析的方法。
// 最终的返回值也即 child 的 dispatchTouchEvent 的返回值,如果是 true,说明该 child (或其子 View)消费了事件
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
// 省略代码 ...
// 调用 addTouchTarget 方法,将找到的接收事件的子 View 保存起来,也会给 mFirstTouchTarget 赋值
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
// 省略代码 ...
}
}
// 省略代码 ...
}
}
// Dispatch to touch targets.
if (mFirstTouchTarget == null) { // 注释 3
// mFirstTouchTarget 为空,说明没找到接收事件的子 View
// 此时调用 dispatchTransformedTouchEvent 方法,传入 View 参数为 null 时,会调用 super.dispatchTouchEvent
// 即调用到上面分析的 View 的 dispatchTouchEvent,以确定是否由当前 View 消费事件
// 这就是事件分发中常见的结论:“事件由父 View 向下传递,如果没有子 View 消费事件,事件又会依次向上传递”
// 实际上并不是向上传递(也就是不是直接调用的 parent.dispatchTouchEvent)而是ViewGroup 先调子 View 的 dispatchTouchEvent 方法,如果没有接收的,再调用自己的 dispatchTouchEvent 方法,以达到“向上传递”的效果
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
// 注释 4 后面讲解
}
// 当为 ACTION_UP 事件时,说明事件序列结束,也会调用 resetTouchState 方法重置状态
// Update list of touch targets for pointer up or cancel, if needed.
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);
}
}
// 省略代码 ...
return handled;
}
-
上面代码虽然有点长,但是关键位置都加了注释,注释很重要,务必结合注释看一遍。总结过程可以得到如下流程图:
ViewGroup_dispatchTouchEvent
将理论应用到需求中
- 分析完 View 的事件传递机制,我们回头解决需求中的遗留问题:子 View 是如何实现让父 View 的 onTouch 事件得不到执行呢?
- 根据分析得知,ViewGroup 会先遍历子 View,子 View 不消费事件的话,ViewGroup 才有机会消费事件。而 ListView 就是一个 ViewGroup,每个 ItemView 就是 ListView 的子 View,如此一来:
- 如果让 ItemView 消费事件,即 onTouchEvent 事件返回 true,则该 View 的 dispatchTouchEvent 也会返回 true,ListView 的 dispatchTouchEvent 在遍历完子 View 后发现有子 View 接收了事件,就没有机会执行**注释 3 **处的代码,更没机会调用 super.dispatchTouchEvent,即没有调用 View 的 dispatchTouchEvent 方法,根据在 View.dispatchTouchEvent 小节中的介绍,在 View.dispatchTouchEvent 中才会调用 OnTouchListener 的 onTouch 方法。
- 相反,如果让 ItemView 不消费事件,在点击区域内 ListView 就没有找到接收事件的子 View,从而调用 View.dispatchTouchEvent,使得 OnTouchListener 的 onTouch 方法得以执行。
- 总之,ItemView 是否消费事件,决定了 ListView 的 OnTouchListener 能否得到执行。
- 至此,我们可以针对需求给出设计方案:为 ListView 设置 OnTouchListener 监听,在 onTouch 方法中隐藏键盘(记得返回false,以便 ListView 的 onTouchEvent 方法和 click 方法还能够得到执行,虽然可能和本例无关)。然后让聊天文本和聊天语音消息对应的 ItemView 能够消费事件,这样 ListView 设置 OnTouchListener 就不能起作用,键盘也就不会消息,满足需求;同理可以让图片消息对应的 ItemView 不消费 Touch 事件,或者消费事件,但在消费事件的方法中自己处理键盘隐藏,并可以再做其他操作,比如微信的放大图片。
- 最后一个问题,如果让 View(包括 ViewGroup)消费掉一个事件的,又事件传递基础知识我们可知,最直接的方式是,让其 onTouchEvent 方法返回 true,并不是要每一类 ItemView 都去重写 onTouchEvent 方法,我们最后再来分析一下 onTouchEvent 方法,看看可以通过哪些设置,让 onTouchEvent 返回 true。
View.onTouchEvent
- 只保留与我们需求有关的代码,精简后的方法如下:
public boolean onTouchEvent(MotionEvent event) {
// 省略代码 ...
final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
// 省略代码 ...
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
switch (action) {
case MotionEvent.ACTION_UP:
// // 省略代码 ...
break;
case MotionEvent.ACTION_DOWN:
// 省略代码 ...
break;
case MotionEvent.ACTION_CANCEL:
// 省略代码 ...
break;
case MotionEvent.ACTION_MOVE:
// 省略代码 ...
break;
}
return true;
}
return false;
}
- 省略后的代码所剩无几,但是对于我们分析问题已经足够了,我们可以看到,当满足 clickable 条件时,无论 Touch 事件的 action 是什么,onTouchEvent 方法都会返回true;相反,当不满足 clickable 也不满足 (viewFlags & TOOLTIP) == TOOLTIP (该条件先不用关心)时,onTouchEvent 就会返回 false。
- 方法开始处给出了 clickable 的来源,即当前 View 是否是 CLICKABLE 或 LONG_CLICKABLE 或 CONTEXT_CLICKABLE,这三个属性可以通过 setClickable、setLongClickable、setContextClickable 来设置,也就是三个属性有任意一个为 true,就会使 if 条件成立,从而使 onTouchEvent 返回 True。
最终方案
- 经过上面的分析,我们再次回到微信聊天页面的需求,可以得出如下切实可行的设计方案:
- ListView 设置 onTouchListener,在 onTouch 方法中实现隐藏软键盘的逻辑;
- 将聊天文本、聊天语音对应的 ItemView 的 clickable 属性设置为 true,使 ListView 的 onTouchListener 得不到执行。
- 如果点击之后有其他逻辑,比如微信的文本消息长按会弹出菜单,也可以直接给 ItemView 设置 setOnLongClickListener,在该方法中,View 会先调用 setLongClickable(true),setOnClickListener 则会调用 setClickable(true)
- 将聊天图片对应的 ItemView 的 clickable 设置为 false,或者如果像微信那样,点击聊天图片,不仅隐藏键盘还要放大图片,就直接设置 setOnClickListener,单独处理键盘隐藏并处理图片放大效果。
知识拓展
-
我们结合一个实例,通过事件传递机制,给出了实现方案,同时也收货了不少知识:
- 点击或者长按(Click、LongClick)事件会在 onTouchEvent 中被调用,那么对于单个 View 来讲,可以得出如下事件优先级顺序:
- onTouch(如果设置了onTouchListener)> onTouchEvent > OnClick
- 如果onTouch返回true,onTouchEvent 得不到调用;onTouchEvent中检查当前是否设置OnClickListener,决定是否执行onClick,因而onTouchEvent优先级高于click。
- 如果把 clickable 属性和 dispatchTouchEvent 方法加进去的话,优先级应该为:
- dispatchTouchEvent > onTouch > onTouchEvent > clickable > OnClick
- View 的 onTouchEvent 默认都会消耗事件,除非是不可点击的(CLICKABLE LONG_CLICKABLE 和 CONTEXT_CLICKABLE 同时为 false)。View 的 LONG_CLICKABLE 默认为 false,而 CLICKABLE 要看具体控件,如 Button 的 CLICKABLE 为 true,TextView 的 CLICKABLE 为 false。
- View 的 enable 属性不影响 onTouchEvent 的默认返回值。
- 点击或者长按(Click、LongClick)事件会在 onTouchEvent 中被调用,那么对于单个 View 来讲,可以得出如下事件优先级顺序:
-
CANCEL 事件的由来
- View 的触摸事件中(onTouchEvent)会包含对CANCEL 事件的处理,那 Cancel 是什么?从何而来呢?
- 答案依然藏在 ViewGroup 的 ouDispatchTouchEvent 中,在上面的注释 4处,单独再拿出来分析下
// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
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; // 注释 4
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
- 外层 else 的分支的意思是,此时 mFirstTouchTarget 不为空,即已经有子 View 接收了事件了,但是在注释 4 处看到,intercepted 又为 true,表示父 View 此时要拦截事件,这种情况下,事件的主导权会重新回到父 ViewGroup,那么接下来就调用了 dispatchTransformedTouchEvent 方法并且传入的 cancelChild 为 true,此方法中变回包装一个 ACTION_CANCEL 的事件传给 child。
- 所以 ViewGroup 的分发很重要,每次分发时,首先处理要不要拦截,其次才去找是不是传给合适的子 View 处理,也就是说在任何分发过程中,父 ViewGroup 都可以进行拦截;同时也警告我们,在重新 onTouchEvent 事件时,不要忽略对 CANCEL 事件的处理。
参考
- 更详细的触摸事件解析参考:Android 触摸事件机制(四) ViewGroup中触摸事件详解
网友评论