View触摸

控件树对触摸事件的分发

Posted by candy1126xx on April 20, 2017

MotionEvent

MotionEvent核心是一个32位的int值。每8位表示一种信息。从低位到高位依次是:事件类型 -> 触摸点索引 -> 触摸点绝对坐标X -> 触摸点绝对坐标Y。

  • mNativePtr:保存Native层MotionEvent对象的地址。
  • getAction():获取 触摸点索引 事件类型
  • getActionMasked():获取事件类型
  • getActionIndex():获取触摸点索引
  • getX()/getY():获取触摸点相对坐标X/Y
  • getRawX()/getRawY():获取触摸点绝对坐标X/Y
  • getPointerCount():获取触摸点个数
  • getPointerId():触摸点索引 -> 触摸点ID
  • findPointerIndex():触摸点ID -> 触摸点索引

消费控件链表和事件组

ViewGroup.mFirstTouchEvent是消费控件链表的表头。链表元素是TouchEvent对象。当发生单点触摸ACTION_DOWN、或多点触摸第一个点ACTION_DOWN时,ViewGroup.mFirstTouchEvent是null;否则ViewGroup.mFirstTouchEvent一定不是null。当ACTION_DOWN发生后,从控件树的根到消费了事件的控件组成一条链表,之后同事件组的事件不必再寻找消费事件的控件,只会沿着这条链表传递。

事件分发流程

在DecorView.superDispatchTouchEvent() -> ViewGroup.dispatchTouchEvent()中:

对ACTION_DOWN事件

  1. 调用ViewGroup.onInterceptTouchEvent(),其返回值决定是否拦截该事件;否则不拦截。
  2. 如果不拦截,开始寻找能够消费该事件的子控件;
  3. 获取触摸点相对坐标X/Y;
  4. 为子控件排序。默认排序是Z值越大越靠后;Z值相同的,在ViewGroup.mChildren中索引越大越靠后;
  5. 倒叙遍历排序好的子控件,找到可见且X/Y落在其区域内的子控件;
  6. 对于这样的子控件,把触摸事件的坐标系转换为相对于子控件的坐标系,然后调用子控件的dispatchTouchEvent();
  7. 如果子控件的dispatchTouchEvent()返回true,说明子控件消费了事件;记录下ACTION_DOWN发生的时间、子控件 在ViewGroup.mChildren中的索引、事件相对于父控件的坐标、用子控件包装成的TouchTarget;
  8. 如果没有进入2,或者进入2后在v.中子控件的dispatchTouchEvent()返回false,则调用父控件的View.dispatchTouchEvent()。
  9. 在父控件的View.dispatchTouchEvent()中,先调用onTouchListener.onTouch(),如果返回false,再调用View.onTouchEvent()。
一个特例帮助记忆

注意到ViewGroup/View内部传递顺序是连续的,于是

  • 记ViewGroup.dispatchTouchEvent() -> ViewGroup.onInterceptTouchEvent()为“ViewGroup两步”,
  • 记View.dispatchTouchEvent() -> onTouchListener.onTouch() -> View.onTouchEvent()为“View三步”,

考虑一颗三层完全二叉控件树,标号1~7。触摸事件传递顺序是:

1.ViewGroup.两步 -> 2.ViewGroup两步 -> 4.View三步 -> 5.View三步 -> 2.View三步 -> 3.ViewGroup两步 -> 6.View三步 -> 7.View三步 -> 3.View三步

ViewGroup的“View三步”返回true,那么以它为根的子树的所有方法依然会被调用;如果不想它们调用,必须onInterceptTouchEvent()返回true。View的“View三步”返回true,则它的兄弟节点一定不会被调用。

对ACTION_MOVE、ACTION_UP、ACTION_CANCEL事件

  1. 调用ViewGroup.onInterceptTouchEvent(),其返回值决定是否拦截该事件;否则不拦截。
  2. 如果不拦截,检查消费控件链表是否走完,即ViewGroup.mFirstTouchEvent.next是否为null,没走完就继续走,把触摸事件的坐标系转换为相对于子控件的坐标系,然后调用子控件的dispatchTouchEvent()。

从方法调用层面来说,与ACTION_DOWN事件的调用链是一模一样的。

缺点

Android的这套事件分发机制有2个重大缺点,一是子View能否接收到事件由父View决定,无法主动申请;二是单点触控的1个事件组只能由1个控件消费,不能在中途转移事件目标。为此,Android有2个补充:ViewGroup.requestDisallowInterceptTouchEvent()嵌套滑动机制。但是仍然有一些需求是要拐着弯做的。

ViewGroup.requestDisallowInterceptTouchEvent()

ViewGroup默认不拦截事件。如果ViewGroup1的onInterceptTouchEvent()返回true,那么它将拦截事件,且不会调用自己的“View三步”。如果调用链上某个位于其后的ViewGroup2,调用它的requestDisallowInterceptTouchEvent(true),那么在其之前的所有ViewGoup都将标记上FLAG_DISABLE_INTERCEPT,且ViewGroup1的拦截将失效,且不会调用ViewGroup1的onInterceptTouchEvent()。

多点触控

ViewGroup.setMotionEventSplittingEnabled()

默认情况下,落在ViewGroup上的、多点触控第二及以后点的事件、只会分发给消费了第一点事件的子控件。调用setMotionEventSplittingEnabled(true),可以使多点触控第二及以后点的事件有机会分发给其它子控件。

转化触摸事件

一般地,我们需要把触摸事件转化为更有意义的事件,比如点击、长按等。

转化为点击、长按

在View.onTouchEvent()中:

  1. 判断控件是否是disable的。一个disable但clickable的控件,也会消费事件,只是不会对事件作出反应。
  2. 调用TouchDelegate.onTouchEvent()。它的作用是使落在该控件的触摸事件转交给另一个控件响应。
  3. 如果控件是不可点击的,直接返回false,表示不消费事件;
  4. 否则,
    • 对DOWN事件反应:回溯控件树,看该View是否在可滑动的ViewGroup内;如果不在,更改Drawable,把状态改为pressed,并发出一个Runnable以反应长按事件;如果在,延迟100ms再做反应。
    • 对MOVE事件反应:如果事件坐标超出了响应范围,删除判断点击和长按的Runnable,更改Drawable和状态。
    • 对UP事件反应:如果之前是在pressed状态下(那么该控件获取焦点,如果还没有响应长按时间,那么删除长按Runnable,响应点击事件。
    • 对CANCEL事件反应:删除判断点击和长按的Runnable,更改Drawable和状态。

Getsture

将一个继承自SimpleOnGestureListener的对象作为参数,创建一个GestureDetector对象,对某个控件调用View.setOnTouchListener(),在onTouch()中返回gestureDetector.onTouchEvent()。这样,就可以扩展该控件可识别的手势:

  • onSingleTapUp():手抬起时,若没有回调过onScroll()和onLongPress(),就回调这个。注意这不一定是单击,也可能是双击的第一击。
  • onLongPress():按住600ms,判断为长按后回调。
  • onScroll():按下后移动一段距离,判断为滑动后回调。
  • onFling():滑动后手抬起,此时若控件滚动速度超过一定值,就判断为抛。
  • onShowPress():按下按键后100ms还没有松开或者移动就会回调。用于改变Drawable。
  • onDown():按下屏幕的时候的回调。
  • onDoubleTap():点击事件发生后,若300ms内又发生DOWN事件,就判断发生了双击事件。注意不需要手抬起。
  • onDoubleTapEvent():双击事件发生后,再接收到MOVE、UP事件,会回调这个。
  • onSingleTapConfirmed():点击事件发生后,若300ms内没有DOWN事件,就判断刚才的点击事件是单击。
  • onContextClick():鼠标右击事件。