美女
思路
1. 原理囊括: 整体上把握触摸机制的设计目标 2. 重要方法分析: 分析他的实现和细节,即设计的实现过程 3. 局限: 虽然触摸事件机制强大,但是并不完美,而是有一定局限的。
基础原理介绍
事件先从ViewPostImeInputStage的processPointerEvent开始发送事件, 第一步是发送到DecorView的dispatchPointerEvent,然后便是 DecorView -> Activity -> PhoneWindow -> DecorView这么一个过程哟。
-
事件处理的一般过程:
一次事件是指down, move...., up这一系列小事件组成的完整事件。
事件由down开始,从控件树自上而下找到对应能消耗down事件的view, 然后回馈到系统底层, 后续的move, up 事件则会来到他的身上,让他来处理。如果最底层的view或者中间拦截的View都没有消耗down事件,那么后续的move, up事件是不会来到他们身上的。
事件是否被认为消耗,就看他的down事件是否被吃掉 (return true),其他的move, up有没有被吃不关心。意思是只要down返回了true, 中间不发生拦截的话,后面的move, up就算返回了false, 依然还是会继续来到消耗的目标控件上来。
-
如果发生了拦截, 事件的拦截策略:
传递途中拦截了down, 被拦截的子view将收不到任何事件; 拦截者自己不消耗,那么拦截对象后面也不会有move,up事件,要消耗才会有后续事件呢. 还有一点值得注意,如果在父容器中拦截了down事件,子view申请父容器不要拦截,是不会生效,因为这时候子容器申请的不要拦截策略还没有被系统读取到,一般不要在down中直接拦截!
拦截了move, 子view是不会接收到move事件的,当前拦截对象即使不消耗(false)也没关系,后面的事件也会到他身上来的。
拦截了up, 子View是不会接收up事件的,这时候被拦截子view的点击事件就没法生效的哦!
- 如果事件找到了目标,且没有发生拦截,当次down-move-up事件一般只能被该目标view一个人使用。如果找到了目标,但是move事件被前面的容器拦截了,那么move事件是没法再分发到该目标控件中(当次手势)。所以看来,如果想先让外面的容器view滚动一下,然后里面的view再去滚动是没法做到了,这也就是触摸事件的局限性之所在了,他的事件消费线路是从里到外,没法再由外到里的哦,因此依靠事件机制, 想让多个view一起滚动只能先里面滚动,然后外面再去滚动。
源码分析
上面的原理介绍,均来自于源码解读和日志,没有源码支撑的一堆罗嗦说出来谁信呀. 源代码版本有点旧,不过简单直白~
1. ViewGroup.dispatchTouchEvent:
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!onFilterTouchEventForSecurity(ev)) {
return false;
}
final int action = ev.getAction();
//触摸点在VeiwGroup控件中的x,y位置
final float xf = ev.getX();
final float yf = ev.getY();
//计算viewGroup本身的scroll, 将scroll数值累计到触摸点上,
//后面计算点是否在控件本身上的时候,当我们viewGroup滚动的时候,子控件的可点击位置要跟随着滚动的内容去变化的,而比较是否在控件内部是与布局边界相比较的,而这边界个又是不变的,因此我们必须要将滚动的数值给补偿回来。
final float scrolledXFloat = xf + mScrollX;
final float scrolledYFloat = yf + mScrollY;
final Rect frame = mTempRect;
//检查子view是否请求不要拦截的标记。
boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
//当次下发的是down事件, 做的逻辑处理
if (action == MotionEvent.ACTION_DOWN) {
//清除前面发生的事件序列的记录消耗目标的target,
if (mMotionTarget != null) {
// this is weird, we got a pen down, but we thought it was
// already down!
// XXX: We should probably send an ACTION_UP to the current
// target.
mMotionTarget = null;
}
//在down事件中检查down是否当前ViewGroup发生拦截
if (disallowIntercept || !onInterceptTouchEvent(ev)) {
// reset this event's action (just to protect ourselves)
ev.setAction(MotionEvent.ACTION_DOWN);
// We know we want to dispatch the event down, find a child
// who can handle it, start with the front-most child.
final int scrolledXInt = (int) scrolledXFloat;
final int scrolledYInt = (int) scrolledYFloat;
final View[] children = mChildren;
final int count = mChildrenCount;
//遍历所有的子view, 目的是为了向下传递down事件,直到有人吃掉了。或者寻到view的末尾节点
for (int i = count - 1; i >= 0; i--) {
final View child = children[i];
//只有view是visible或者正在执行动画。才会去检测
if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE
|| child.getAnimation() != null) {
//设置child的边界位置到frame中
child.getHitRect(frame);
//这里会判断前面计算的触摸点是否在某个child内部。
if (frame.contains(scrolledXInt, scrolledYInt)) {
// offset the event to the view's coordinate system
final float xc = scrolledXFloat - child.mLeft;
final float yc = scrolledYFloat - child.mTop;
ev.setLocation(xc, yc);
child.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
//如果在某个child内部就向下分发,这里很重要,view的层级一般都有很多的
//这里其实会发生递归调用。当一级ViewGroup往下分发到二级viewgroup的时候
//同样会卡在这里往下调用到第三级view,直到找到最末尾的view,判断它是否消耗
//然后一层层在这里返回。
if (child.dispatchTouchEvent(ev)) {//递归
// 当down事件找到了处理目标
mMotionTarget = child;
return true;
}
// The event didn't get handled, try the next view.
// Don't reset the event's location, it's not
// necessary here.
}
}
}
}
}
//是否是up, 或者是cancel事件;
boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||
(action == MotionEvent.ACTION_CANCEL);
//如果是up, 会清除前面的禁止拦截标记
if (isUpOrCancel) {
// Note, we've already copied the previous state to our local
// variable, so this takes effect on the next event
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}
final View target = mMotionTarget;
//1. 假如前面的down事件没有找到目标,我就自己来处理了,即调用View.dispatchTouchEvent, 这个方法本质是就是调用View.onTouchEvent.
//2. 或者当前viewGroup拦截了后面的事件,那么我就自己来处理啦。
if (target == null) {
// We don't have a target, this means we're handling the
// event as a regular view.
ev.setLocation(xf, yf);
if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {
ev.setAction(MotionEvent.ACTION_CANCEL);
mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
}
//当前容器自己来消耗啦。
return super.dispatchTouchEvent(ev);
}
//走到这里来,首先肯定是move,up事件。检测是否发生了拦截
// if have a target, see if we're allowed to and want to intercept its
// events
if (!disallowIntercept && onInterceptTouchEvent(ev)) {//如果move, up发生了拦截
final float xc = scrolledXFloat - (float) target.mLeft;
final float yc = scrolledYFloat - (float) target.mTop;
mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
ev.setAction(MotionEvent.ACTION_CANCEL);
ev.setLocation(xc, yc);
//将cancel发给被拦截的子view, 发个告示意思一下,后面大爷来处理来了。
if (!target.dispatchTouchEvent(ev)) {
// target didn't handle ACTION_CANCEL. not much we can do
// but they should have.
}
// clear the target
//清除本身记住的子view.但是他作为target记录在他的父容器中没有被清除,下次事件就还会
//发送到他自己身上。
mMotionTarget = null;
// Don't dispatch this event to our own view, because we already
// saw it when intercepting; we just want to give the following
// event to the normal onTouchEvent().
return true;
}
if (isUpOrCancel) {
mMotionTarget = null;
}
// finally offset the event to the target's coordinate system and
// dispatch the event.
final float xc = scrolledXFloat - (float) target.mLeft;
final float yc = scrolledYFloat - (float) target.mTop;
ev.setLocation(xc, yc);
if ((target.mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) {
ev.setAction(MotionEvent.ACTION_CANCEL);
target.mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT;
mMotionTarget = null;
}
//这里是通常的流程,即没有发生拦截,又有target.就往他身上发事件啦。也是递归地往下发,因为target是一层层的记录的,找到最终的target一般都是view的子类,然后调用他的dispatchTouchEvent, onTouchEvent.
return target.dispatchTouchEvent(ev);
}
2. 举个例子吧
前面的核心源码其实有很多的递归调用,理解起来可能有些繞。用例子可能比较好懂些呢。
布局一:LinearLayout -> FrameLayout ->TextView; 布局二:LinearLayout -> FrameLayout ->Button。
-
布局一:
-
当down事件下发时候,LinearLayout会在这里找到对应的子child-frameLayout, 然后调用他的dispatchTouchEvent。
if (child.dispatchTouchEvent(ev)) {//递归处 // 当down事件找到了处理目标 mMotionTarget = child; return true; }
FrameLayout.dispatchTouchEvent, 走的还是ViewGroup的dispatchTouchEvent,即递归执行该方法,当又走到这个判断的时候,就会调用TextView.dispatchTouchEvent, 这会执行View.dispatchTouchEvent, 这个方法主要是调用View.onTouchEvent.由于TextView的onTouchEvent默认返回false. 所以在递归处返回了false, 即FrameLayout.dispatchTouchEvent在这里得到了false返回, 然后不走if, mMotionTarget=null, 继续往下执行:
if (target == null) {//没有找到消耗的目标 // We don't have a target, this means we're handling the // event as a regular view. ev.setLocation(xf, yf); if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) { ev.setAction(MotionEvent.ACTION_CANCEL); mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT; } //当前容器自己来消耗啦。 return super.dispatchTouchEvent(ev); }
在下面找不到消耗对象后,调用super.dispatchTouchEvent(ev),也就是viewGroup本身的onTouchEvent来处理了,
也就是常说的如果子控件不消耗就自己来消耗这个意思
。然后根据他的onTouchEvent结果来向上反馈, 这就在LinearLayout的if (child.dispatchTouchEvent(ev))
递归处得到了返回,走Framelayout同样的逻辑,Framelayout和LinearLayout他们的onTouchEvent一般都是返回false.所以当次down事件在这个布局层级中是没有找到消费目标的,后面的move, up事件是不会来到这个布局结构中的哦,这个在View体系源码中好像看不出来为什么不下来了,是从日志得出这个结论的哦。不知道我有没有说清楚啊......
-
-
布局二:
-
当down事件下发时候,流程和布局一 是一样的,只是在button处的onTouchEvent返回了true, 然后在
FrameLayout.dispatchTouchEvent
他的内部递归处返回了true, 所以进入了if判断,存储他的目标对象target=button, 然后立即返回true,FrameLayout.dispatchTouchEvent
返回了true,回到LinearLayout.dispatchTouchEvent
处,继续记录他的target(fm), 然后继续网上返回,也就是层层递归返回啦。down就这样结束了他短暂的一生......if (child.dispatchTouchEvent(ev)) {//递归 // 当down事件找到了处理目标 mMotionTarget = child; //viewGroup立即返回true. return true; }
-
当move, up事件下来的时候,会跨过前面所有的地方,直奔最后一行:
//这里是通常的流程,即没有发生拦截,又有target.就往他身上发事件啦。也是递归地往下发,因为target是一层层的记录的,找到最终的target return target.dispatchTouchEvent(ev);
在这里先调用FrameLayout.dispatchTouchEvent, 然后FrameLayout又走一样的流程调Button.dispatchTouchEvent。因为LinearLayout中的target是frameLayout, frameLayout的target是button, 这样直奔Button的onTouchEvent.也就是常说的
谁消耗了down事件,后续move,up事件都会来到谁身上
。
-
-
假如前面出现了拦截,比如布局二中FrameLayout的在move这里搞了一次拦截,那么情况可能就和上面有不一样了哦,我们看看code:
if (!disallowIntercept && onInterceptTouchEvent(ev)) {//如果发生了拦截onInterceptTouchEvent返回true. final float xc = scrolledXFloat - (float) target.mLeft; final float yc = scrolledYFloat - (float) target.mTop; mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT; ev.setAction(MotionEvent.ACTION_CANCEL); ev.setLocation(xc, yc); //将cancel发给被拦截的子view, 发个告示意思一下,后面大爷来处理来了。 if (!target.dispatchTouchEvent(ev)) { // target didn't handle ACTION_CANCEL. not much we can do // but they should have. } // clear the target //清除本身记住的子view.但是他作为target记录在他的父容器中没有被清除,下次事件就还会 //发送到他自己身上。 mMotionTarget = null; // Don't dispatch this event to our own view, because we already // saw it when intercepting; we just want to give the following // event to the normal onTouchEvent(). return true; }
如果在Fm容器中发生了拦截,那么会发一个cancel给到Button, 其次会清除Fm中的target, 直接返回了不继续下发了. 那么下一次呢, 下一次move事件再来的时候呢,会有啥变化呢,这时候fm中的target变成了null ! 那么就会在这里有了新的故事:
if (target == null) {//Fm中的target为null,进入if体 // We don't have a target, this means we're handling the // event as a regular view. ev.setLocation(xf, yf); if ((mPrivateFlags & CANCEL_NEXT_UP_EVENT) != 0) { ev.setAction(MotionEvent.ACTION_CANCEL); mPrivateFlags &= ~CANCEL_NEXT_UP_EVENT; } //FM自己来消耗新的move事件啦。 return super.dispatchTouchEvent(ev); }
所以可以看到,fm中的target为null了,他就没法走到最后一行去向Button去分发move事件啦。这就是为什么拦截了事件,子view收不到消息的原因啦!(值得注意的是,谁拦截了move,不管move返回的是true,还是false, 后续事件都会给他来吃了。从上面也可以看到端倪,因为没人来针对move的返回结果来清除target呀,所以哥就不管37二十七一直发咯!)
冬梅?啥?问你懂没---
3. View.dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent event) {
if (!onFilterTouchEventForSecurity(event)) {
return false;
}
//onTouch来处理啦
if (mOnTouchListener != null && (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener.onTouch(this, event)) {
return true;
}
//前面不处理或者是false, 我就来处理啦。
return onTouchEvent(event);
}
/ View.dispatchTouchEvent很简单, 如果onTouch返回了true就不会给onTouchEvent, 否则将事件传递给onTouchEvent, dispatchTouchEvent的返回数值代表的就是onTouchEvent返回的数值。代表着有没有消耗触摸事件。
4. View.onTouchEvent:
该方源码比较简单, 这里就不看了, 简单记录下内容
- 如果控件是clickable, long_clickable那么就会返回true, 表示可以消费此事件。所以textView是不消耗, Button消耗的啦。
- ACTION_DOWN:如果是在scrollView类似这样的容器中,会延迟100ms来做按压态显示,并作长按事件检测. 如果不是在滚动容器内, 直接显示按压态, 然后做长按事件检测(长按事件是500ms没抬起就执行longClick事件)。
- ACTION_CANCEL:设置按压状态为false, 取消点压事件检测(100ms之后要改变view状态), 以及长按事件检测(500ms之后响应longClick)。
- ACTION_MOVE:当点击位置不在view内时候,move事件还是会下发到我们当前控件上来的,这和down不一样,android这样设计估计是为了更好地用户体验吧,让触摸范围更大。然而,虽然事件来到了当前的view, 如果不在view位置内是会移除longClick事件和presesed事件的。
- ACTION_UP: 如果在down事件设定的是prepressed, 则立即显示点压状态; 当没有执行longClick或者longClick返回为false的情况下才会去执行onClick事件,然后在执行一个延迟任务来释放前面的点压状态,不管点压态是在up中设定的还是在down中设定的。
- 注意的是,如果当次点击事件在寻求焦点的获取, 那么当次点击事件是不会生效的。下一次才会生效,这就是开发过程中为什么有时候点击两次才能响应点击事件。解决思路从焦点角度来研究。
细节
- dispatchTouchEvent: 表示下面有没有人消耗了触摸事件。
- 一个clickable或者longClickable的View会永远消费Touch事件,不管他是enabled还是disabled的。
- move事件和up事件并不一定是共存的,可以只有down和up。
- 在ACTION_DOWN中,如果当前View没有设定拦截,遍历子view结构,寻找目标View, 当找到了能目标view,也就是他的onTouchEvent能返回true. 如果后续控件体系仍然对move, up事件未添加拦截,那么后续的事件都会来到他身上。
局限所在
- 触摸事件传递机制是android的ui交互处理的一套非常重要,也很经典的机制。看上去很漂亮优秀,但是也有很大的先天不足,像前面所说一次触摸事件在这样的机制下是没法做到先让容器滚动然后再是子view滚动的操作了。比如在一些大型商用app上买菜的,购物的,音乐的等等,他们的很多内容页面是有很漂亮流畅的滚动效果的,这是单靠基本的触摸滚动应该是做不到的。那是什么呢,我想是应该是灵活地运用了嵌套滚动机制吧,可以将一次触摸事件分时分量地给到多个控件呢。对的,嵌套滚动哦~