android 触摸事件详解(完结)
冲突的原因
电脑本来设计好了最简单的规则,down事件碰到哪个控件,哪个控件就接收全部事件,是原始的以人为本!
但人偏偏喜欢打破规则。或者是偷懒,便捷缘故,如scrollView.不需要设计旁边下拉条。人就想往中间拉,管你碰到中间什么控件,我就要滑动的事件。
为什么电脑端没有这么多冲突?因为电脑端时代,是键盘,和鼠标,人还算守规则,要滑动,用滚动条啊,有了触摸后。拉什么拉,我要流的滑。
所以出现了截断,截断就像是潘多拉之盒,虽然有了截断的便利,也带来了世界的混乱。
因为事件的处理是从控件树的上层到下层,截断后,上下之间顺序优先独占,虽然对于scorllview,大部分是ok的。
但有时候这又和我们人类感觉的不匹配。为什么下滑里面的不让下滑,因为下滑不知道里面还有下滑啊,下滑必须掌控下滑动作。不行,下滑碰到下滑,要给里面下滑。 等等。
所以才会产生控件间事件该如何分配的问题。
所以总结
电脑的设计(无冲突,有时候不那么方便):down碰到谁给谁,铁一般的纪律。要滑动,用滚动条。
人类的感觉(全部截断):0.有时候,手碰到了子控件,还是要把事件给上层,所以就产生了截断,打开了混乱的源头。如scorllview:在子控件上滑动,事件还要归scrollview。
人类的感觉(外部截断):1.有时候,上层截断后,需要把上层不需要的动作,分配给下层。 所以需要上层,精确控制自己的截取,放行自己不需要的。
人类的感觉(内部截断):2.有时候,上层截断后,需要把下层需要的动作, 分配给下层。所以需要上层,先放行down, 让下层执行getparent().disallowinterxxxxx. 这样让下层掌握控制权。
我的感觉:优先碰到谁给谁,如果一定要截断,那么只截取自己的。如果子和我要抢同一个事件,那么优先看看是否可以避免这种设计。所以尽量不用getparent().disallowinter.
尽量用外部截断法来分配滑动,因为简洁,容易理解和定位bug,只有一种情况是必须使用内部截断法的。就是内外需要分配的事件是同一个,从业务上无法区分的动作,而且此动作应该给子控件。那么就必须内部截断法。
所以本来,从下往上一个listener.touch就可以工作。一切的起源都是上层想要截断事件。所以才有onintercetp+ontouch. 为了解决截断的特殊情况又出现了disallowflag. click感觉是一个动作语法糖而已。
个人名词修正
滑动冲突,因为修改为滑动分配。这样更容易理解本质。
因为本质上就是如何分配事件。不管和外部截断和内部截断。
截断的目的就是分配。
diapatchEvent:个人感觉应该翻译为下发,而不是分发。
有3个苹果,都给一个小朋友,是下发。给3个才叫分发。很明显,事件最终是一个人处理。只是看看给谁而已。
直译很多情况下,都会发生意思偏差。
触摸设计的推导假设
从直接触碰的控件往上传播所有事件,包括down和move,up。
这样同一个枝的控件都可以知道所有事件。设置一个listener就可以工作。
设置一个字段,isHandle,是否掌控。一但为真,那么就不再往上传。
这个设计很简单,从下往上符合人的感知和经验。
随时可以触发自己的动作。触发了自己的,设置下ishandle.
为什么这么简单的流程不用。要搞的这么复杂?
因为有特例要要上层截断,所以才有截断判断,还可以设置截断条件。
截断又搭配一个ontouch,放在listener.touch之后,比较符合常理。
又想优化下每次事件的传播效率,才有down作为判断消费者的设计。不必要每次都传到最底层。
截断后,又想要特例,所有又有了 disallow.
触摸事件的伪代码
首先<<android 开发艺术探索>>和网上的伪代码是一样的,估计大家都是抄书的,但是个人感觉有非常明显的失误。都是一抄全错。 书上的伪代码,看来是down的伪代码,但是尾递归之后又少一个很重要的,兜底处理。 自己理解的伪代码,分为down和其他事件。因为差别挺大,分为2个部分更容易理解。
down 伪代码
down event public boolean dispatchTouchEvent(MotionEvent ev) { boolean consume = false; if (onInterceptTouchEvent(ev)) { consume = TouchListener.onTouch(ev)->this.onTouchEvent(ev)->ClickListener.onClick(ev); } else { consume = child.dispatchTouchEvent (ev) ; if(consume==false) { consume = TouchListener.onTouch(ev)->this.onTouchEvent(ev)->ClickListener.onClick(ev); } } return consume; }
move:伪代码
public boolean dispatchTouchEvent(MotionEvent ev) { boolean consume = false; if (target==null)//没有下发目标,自己处理. 有2种情况 1.最早截断过down. 2.上次截断过move { consume = TouchListener.onTouch(ev)->this.onTouchEvent(ev)->ClickListener.onClick(ev); } else { if (onInterceptTouchEvent(ev)) { ev=cancel; consume = child.dispatchTouchEvent (ev) ; target=null; } else { consume = child.dispatchTouchEvent (ev) ; } } return consume; }
详细流程图,
分为down事件和非down事件。
down 事件
非down事件
典型事件图
分析过程
一。自己的总结。 从大的说,其实就是一个递归。
1.down的目的就是找到谁来处理事件,循环所有子控件,一直往下(dispatchTouchEvent)问(onintercepevent),只要有控件截断,那么之后所有事件的终点站就是它了。
都不处理,那么递归出来时再沿往回问(touchListener + clickListener)。这样,通过down事件找到了谁来处理,
2.那么其他事件就不需要循环所有子控件了,直接走处理链的那一条路(dispatchTouchEvent),一直到目标,调用它的touchListener + clickListener
中途,有截断的话,那么就把处理者由原处理者更改为截断者。特殊情况down的时候发现没有处理的view,那么交给activity处理。
3.调用touchListener + clickListener,一般说成Listener.Ontouch 和 onTouchEvent. 因为onTouchEvent的基类实现就是调用view.onclick.我们重写onTouchEvent就是覆盖view.onclick
细致点就是先 touchlistener,如果返回flase,再onclickListener.
网上都是任务下派来作为比喻,很好。只不过大部分没有详细点明一些细节。自己详细比喻下。
假如某公司有多级部门。总公司中心处是activity。activity 总公司中心处 不记住任何东西。只派发任务,并处理大家都不处理的任务。而group会存储是否有我的分部门处理这件事。而分部分又会记载分分部分。直到分分分分分记录了某个人。
从总公司中心处,派发任务,当派发了某个任务,这里就比喻为点击了某处。那么就把这个任务给相关部门,此部门,一层一层的下放到最小的部门的某个人。 当然如果是好差事,中间会有截取。
如果下放到某个人,或者被中间某人截取,但是他后来才发现他没有能力处理(也就是某个控件,触摸事件点到它了,但是它没有消费down)。那么就一层一层沿来路往上,看看谁能处理。
这里就比喻 down下发时候的ontouchEvent都返回false的回归逻辑。最终有人处理,或者真的无人处理。这里
ontouchEvent包括我们的click和自定义的ontoucheventlister。down返回了true。那么和截断一样。就确定了处理人。下次会逐层传递到这里为止,也就是还是会从上往下询问是否需要中断,但是不会再像down一样往回问处不处理。因为已经有人处理了。
之后如果有这个任务的后续处理事件,就比喻为move,up事件。 那么还是从总公司中心处,一层一层过来(很多文章都是说交给某人处理,没有强调是从上往下一层一层的),直到交给处理这个任务的人,就不再往下了。
这里就是比喻其他事件,也是递归进去,并比较是否是当初存储的那个处理的view。是,就停止递归。
当然中间也可以再截取这个任务,然后再一层一层的通知原来处理这个事情的人,这个任务作废了。也就是比喻为中间截取了move或up信号,并一层一层发送cancel事件到down处理者。只发送一次cancel。以后截取的view就成为了新的处理者,截断所有事件。
如果当初是无人处理。那么后续事项,总公司中心处,还是需要先发给大部门,大部门知道无法处理。就直接说无法处理。总公司才自己处理。Activity是不存储谁处理事情的,只有group才存储。所以就算没有处理。activity还是要先问下顶级group。
最佳实践
1.最方便是只写 listener.
2.如果需要上层覆盖下层。那么最好是只用外部截断法。套用固定套路。
2.1 down,up,cancel 都放行。
2.2 对于move,只截断自己需要的,尽量吧范围缩小。
3.实在是无法区分上下事件,无法区分也就是无法下放下层事件,那么就用内部截断法。也是固定套路。
3.1 ondispatchEvent中。down事件,就告诉上级不要截断事件。
3.2 必要的话,可以放弃通过down事件获得的事件接收权。
4.要注意分辨,onIntercept和listener消费的区别和含义。
4.1 onIntercept的目的是截取我要的动作。获得控制权。 所以一般对于down是要放行,以便让down走到最接近人触摸点的位置,以便符合人的感觉。 而对于move动作,需要就必须截断。以符合人的最早的动作意图就是我的本意的习惯。
4.2 listener+touch的目的是是否消费这个动作。有2种情况,进入此函数。
1.没有任何子空间消费down,那么down会进入此函数问我是否消费。
2.如果截断了事件。那么进入此函数会问我是否消费。 所以listener必须覆盖这2中情况。这2中情况的余集,就是对于情况1对于down的处理。
所以一般listen是必须消费down和up.正常处理move。
固定套路
外部截断法。
public MotionEvent mDownEvent=null;//down 动作。 因为down是不会被截断的。所以不会进入listener+touch。所以最好保存下,给listener+ontouch使用。
private MotionEvent mLastInterceptEvent=null;//最新的move动作。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
{
if(ev.getAction()==MotionEvent.ACTION_DOWN)
{
mDownEvent=MotionEvent.obtain(ev);//必须copy。因为ev是一个被所有事件共同使用的变量,随时会被更新,而不是new。
return false;
}
else if(ev.getAction()==MotionEvent.ACTION_MOVE)
{
boolean res=false;
if(需要)//只截断左右滑动。
{
res=true;
}
mLastInterceptEvent=MotionEvent.obtain(ev);
return res;
}
else if(ev.getAction()==MotionEvent.ACTION_UP)
{
return false;
}
else//cancel 应该只有下级的cancel才会经过这里。如果是自己cancel。是会直接进入listener+ontouch.所以必须放行。
{
return false;
}
}
内部截断法
内部截断法,对于我看来。就是外部截断法的补充。所以内部截断法中的上层的代码包括外部截断法的ontercept.
@Override
public boolean dispatchTouchEvent(MotionEvent ev)
{
if(getParent()!=null && ev.getAction()==MotionEvent.ACTION_DOWN)
{
getParent().requestDisallowInterceptTouchEvent(true);
}
if(getParent()!=null&& ev.getAction()==MotionEvent.ACTION_MOVE && 上层需要)
{
getParent().requestDisallowInterceptTouchEvent(false);
}
return super.dispatchTouchEvent(ev);
}
一个实际例子
内部控件
public class MyHorizontalScrollViewEx extends HorizontalScrollView { public MyHorizontalScrollViewEx(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { if(getParent()!=null && ev.getAction()==MotionEvent.ACTION_DOWN) { getParent().requestDisallowInterceptTouchEvent(true); } return super.dispatchTouchEvent(ev); } }
外部控件
public class MyConstrainLayoutEx extends ConstraintLayout { public MotionEvent mDownEvent=null;//down 动作。 因为down是不会被截断的。所以不会进入listener+touch。所以最好保存下,给listener+ontouch使用。 private MotionEvent mLastInterceptEvent=null;//最新的move动作。 private PointF mdisInterceptStart=null; private PointF mdisInterceptEnd=null; public MyConstrainLayoutEx(Context context, AttributeSet attrs) { super(context, attrs); } //v1.分配事件。放行click,一旦有move,那么之后就全部要。要注意,up放行。前提是没有触发move,move触发后,表示截断,那么onInterceptTouchEvent是不会再执行的。之后的move和up是会直接给listener+ontouch //v2.改动就在于截断move的时候加了一个条件判断。其他基本没动。 //v3.如果同向,可以提供一个方法,用于告诉group,再那个区域的不要截断。好像这样和内部截断的功效一样,内部也是告诉group。别截断,但是本质是不一样的。内部法是内部从此掌握了所有事件。 //如果上层还想要。必须内部放行。而我们画蛇添足的加入一个方法让外部调用。本质上还是上层控制主动。好处是耦合低,如果内部法有一个方法,可以让上层重新掌握主动。而不是靠内部来判读,那才算是耦合度合理。 //但是不可能有,因为外部法,就是由于无法通过已有的方法,分辨出何时该放。何时该收。但是google为什么不多提供一个接口呢,而不是只能用内部这种不完美的方案。 @Override public boolean onInterceptTouchEvent(MotionEvent ev) { if(ev.getAction()==MotionEvent.ACTION_DOWN) { mDownEvent=MotionEvent.obtain(ev);//必须copy。因为ev是一个被所有事件共同使用的变量,随时会被更新,而不是new。 return false; } else if(ev.getAction()==MotionEvent.ACTION_MOVE) { boolean res=false; if(mDownEvent!=null && ev!=null)//只截断左右滑动。 { LSTouch.scrollDirection direction=LSTouch.getscrollDirection(mDownEvent, ev); if(direction==LSTouch.scrollDirection.LEFT || direction==LSTouch.scrollDirection.RIGHT) { // if(ev.getRawX()>=0 && ev.getRawY()>=200)//这里做一个假设,可以提供一个方法,传递某个控件的位置,这样当触摸点在这个位置,那么不能截断。也是可以的。 // { // res=false; // } // else // { // res = true; // } res=true; } } mLastInterceptEvent=MotionEvent.obtain(ev); return res; } else if(ev.getAction()==MotionEvent.ACTION_UP) { return false; } else//cancel 应该只有下级的cancel才会经过这里。如果是自己cancel。是会直接进入listener+ontouch.所以必须放行。 { return false; } }
未解决的疑点
1.当有匹配的事件发生,只给下面说你的事件取消了,但是不告诉自己去触发事件? 这样不是浪费了一个事件了不?虽然很多情况下是无关紧要,但是逻辑上还是错误啊。万一下一个事件就是up事件呢?所以截取一定不能截取up?否则不会触发自己的touch事件!!!
解决:en .可以在onintercept,设置一个变量,来告诉事情已经发生了。如果最后一个是up。那么就直接触发动作。不需要touch事件。否则,根据定义好的变量,在touch中直接做动作,后面的事件直接消费就好了,不作为事件是否发生的标志。
2.如果截断后产生了新的事件消费者控件,事件都已经触发了,假设它上层某个控件有个事件,又匹配上了用户的后续动作呢?,又要截断? 那要触发2个动作。不符合人的常识啊。
解决:可以在截断后,设置 disallow为true。这样保证上层不会再截止动作了。只有我们自己一个动作执行者。
补充 activity ,window, dector的处理分析
C:\android\sdk\sources\android-28\android\app\activity.java /** * Called to process touch screen events. You can override this to * intercept all touch screen events before they are dispatched to the * window. Be sure to call this implementation for touch screen events * that should be handled normally. * * @param ev The touch screen event. * * @return boolean Return true if this event was consumed. */ public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { onUserInteraction(); } if (getWindow().superDispatchTouchEvent(ev)) { return true; } else { return onTouchEvent(ev); } } private Window mWindow; public Window getWindow() { return mWindow; } mWindow = new PhoneWindow(this, window, activityConfigCallback); C:\android\sdk\sources\android-28\android\view\window.java /** * Used by custom windows, such as Dialog, to pass the touch screen event * further down the view hierarchy. Application developers should * not need to implement or call this. * */ public abstract boolean superDispatchTouchEvent(MotionEvent event); C:\android\sdk\sources\android-28\com\android\internal\policy\PhoneWindow.java @Override public boolean superDispatchTouchEvent(MotionEvent event) { return mDecor.superDispatchTouchEvent(event); } mDecor = (DecorView) preservedWindow.getDecorView(); mDecor = generateDecor(-1); DecorView就是Window的顶级View,它派生于FrameLayout,而FrameLayout又派生于groupview。所以我们可以最后追到ViewGroup.java 所以最终看ViewGroup.java的dispatchTouchEvent就可以。但是需要配合下面这幅图。其中contentViews是我们的布局xml文件的内容。
C:\android\sdk\sources\android-28\com\android\internal\policy\DecorView.java public boolean superDispatchTouchEvent(MotionEvent event) { return super.dispatchTouchEvent(event); }