View事件分发机制

事件这里指的是一系列的MotionEvent(android.view.MotionEvent)类对象,实际上是一个动作码和一个坐标轴值的集合,动作码指明了在触摸时发生的变化,坐标轴值含有位置,时间等运动属性信息,常见动作类型即action code如下所示:

ACTION_MASKED 描述
ACTION_DOWN 当手指第一次触摸屏幕的时候产生,是一个事件的开始,包含着初始位置等信息,该指针的指针数据索引始终为MotionEvent中0
ACTION_MOVE 当手机在屏幕上移动的时候,产生一系列的MOVE,包括坐标轴和其他的运动属性
ACTION_UP 最后一根手指离开屏幕时产生,标志着事件的结束(或者是ACTION_CANCEL)
ACTION_CANCEL 动作终止,类似于ACTION_UP,但是不执行任何正常状态下要触发的动作
ACTION_POINTER_DOWN! 超出第一个进入屏幕的触摸手指,多点触控的情况,对应的数据由getActionIndex()返回的索引获取
ACTION_POINTER_UP 非最后一根手指离开屏幕,对应多点触控的情况

当屏幕接收到点击,就产生了一个事件,紧接着就会触发一系列特定的方法,一套完整的事件分发机制,从上到下依次是Activity→ViewGroup→View,实际上是按照视图的层次结构进行分发的。

Activity对事件的分发

事件首先传递给当前屏幕上对应的Activity,它是用来和用户进行交换的窗口,每个Activity都会有一个用于绘制其用户界面的窗口,窗口通常会充满屏幕。
Activity要执行的是dispatchTouchEvent(MotionEvent ev)函数,它可以把捕获到的动作传递给根视图。

 public boolean dispatchTouchEvent(MotionEvent ev) {
     if (ev.getAction() == MotionEvent.ACTION_DOWN) {
         onUserInteraction();
     }
     if (getWindow().superDispatchTouchEvent(ev)) {
         return true;
     }
     return onTouchEvent(ev);
 }

首先如果是触摸事件的开始,即对应动作是ACTION_DOWN,进入if判断内部的onUserInteraction(),该回调函数的意义是表明了用户早与当前Activity以某种方式进行交动,这些输入事件可能来自键盘,触摸或者轨迹球。与该函数对应的onUserLeaveHint()可以一起重载,用以智能地管理状态栏通知的活动等。

然后将事件交给Window进行分发,如果返回值不为true(一般因为超出Window边界之外没有View去接收触摸事件),则执行Activity本身的onTouchEvent(),这是最后的保障手段,默认返回值为真,表明动作已经被消耗处理。

而getWindow()获取的Window类对象是一个抽象类,可以控制顶层类的外观和行为策略,它的唯一实现类是android.view.PhoneWindow 。PhoneWindow实际上把事件传递给了DecorView。

DecorView是FrameLayout的子类,是最顶层的视图,包含标题栏(title)和内容栏(content)。对应于它的唯一一个子视图结构LinearLayout中的两个FrameLayout子元素,其中一个是标题栏,会随着主题的不同而不同,另一个是内容栏,此外内容栏ID:Android.R.id.content是固定的。而我们经常在调用的setContentView(View view),就是指的这个名称为content的视图。

public boolean superDispatchTouchEvent(MotionEvent event) {
    return super.dispatchTouchEvent(event);
}

DecorView中该函数的内容为super.dispatchTouchEvent(),而FrameLayout中并没有这个方法,继续向上,最终实际上对应的是ViewGroup中的这个方法。如此,整个触摸事件的动作便从Activity传到顶级View。

ViewGroup对事件的分发

函数dispatchTouchEvent(MotionEvent ev),返回值表示事件是否分发处理。

清除状态

首先是通过onFilterTouchEventForSecurity(MotionEvent)函数过滤TouchEvent,如果被拦截就直接返回FALSE。之后获得动作类型,如果是ACTION_DOWN,表示一个新的手势动作开始了,就取消清除所有之前的TouchTarget记录,该类是一个单链表数据结构;并且重置触摸状态,例如将FLAG_DISALLOW_INTERCEPT标志位重置为0,表示允许父视图中断事件。

判定拦截

然后是判断是否拦截事件,ViewGroup在两种状态下会拦截事件,当动作为ACTION_DOWN或者mFirstTouchTarget非空,具体代码如下:

// 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;
}

当一个事件开始时(ACTION_DOWN),必然要判断是否拦截该事件。另一方面,函数会记录下哪一个子View消耗了该事件,以便之后把后同一个事件序列的所有动作都交给它处理。如果当前事件被该ViewGroup拦截,那么mFirstTouchTarget的值就为null,不管后续到来的动作是什么,判决条件都是FALSE,即始终拦截后续的事件。即没有触摸目标并且这个动作不是初始按下,就直接拦截该事件。

下一步是是获取FLAG_DISALLOW_INTERCEPT标志位的情况,这个标志位可以通过调用requestDisallowInterceptTouchEvent(boolean disallowIntercept)方法设定的,参数值为true,表示不允许拦截事件,不执行onInterceptTouchEvent函数,否则就执行。
在ViewGroup不拦截正常分发MotionEvent时,每一个动作都会经过onInterceptTouchEvent()方法。即onInterceptTouchEvent()函数使父视图有机会在子视图接收到事件以前,看到它所要接收处理的事件。

onInterceptTouchEvent()返回值含义

返回值 描述
true 拦截MotionEvent事件,这表示它不会被传递给子View,先前正在消耗处理事件的子视图会收到ACTION_CANCEL,并且从该点开始的所有后续事件将发送到父节点的onTouchEvent()方法
false 简单地监视事件,事件依旧沿着视图层次结构传播到通常的目标,使用目标的onTouchEvent()方法处理事件
**拦截判断**

事件向下分发

如果该ViewGroup没有拦截事件的时候,事件继续向下分发,遍历所有子视图,找到一个子视图来接收事件。

首先判断视图是否可以接收点事件,当视图是可见的,视图正在或者计划播放动画效果都是可以接收点击事件的,同时判断事件对应的坐标点是否在子视图的区域内,不满足条件的跳过,进行下一个。

if (!canViewReceivePointerEvents(child)
        || !isTransformedTouchPointInView(x, y, child, null)) {
    ev.setTargetAccessibilityFocus(false);
    continue;
}

最终在dispatchTransformedTouchEvent(MotionEvent event, boolean cancel, View child, int desirePointerIdBits)函数中,调用了子视图的dispatchTouchEvent方法,这个过程中,对触摸事件的位置进行了转换操作,实际上就是根据Scroll计算了位置偏移。这样就将事件分发给了子视图。

如果子视图返回值为true,那么就可以确定分发对象,跳出for循环。在此之前调用addTouchTarget方法,函数中对mFirstTouchTarget(TouchTarget类对象)赋值,它影响着ViewGroup的分发拦截方式,不为null时,当后边的一系列动作到来时,就可以直接传递给相应的视图,否则会拦截同一手势序列中的所有触摸事件。

如果遍历结束以后,都没有找到消耗事件的子视图,那么ViewGroup会自己处理该事件,调用dispatchTransformeTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS)函数,把子视图参数设置为null,即把自身当做一个普通的View,调用父类View的dispatchTouchEvent方法。

View对事件的处理

View是一个单独的视图,没有子视图需要分发,所有直接由自身处理。

调用onTouch方法

首先是判断View有没有设置触摸监听(View默认情况下是ENABLE的),以及是否设置OnTouchListener接口的回调函数onTouch(View v, MotionEvent event),如果设置了,则调用该函数并取得处理之后的返回值。为true就不会调用后续的onTouchEvent方法,显然onTouch方法优先级高于onTouchEvent方法;如果返回值是false,则调用onTouchEvent方法。

ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
        && (mViewFlags & ENABLED_MASK) == ENABLED
        && li.mOnTouchListener.onTouch(this, event)) {
    result = true;
}
if (!result && onTouchEvent(event)) {
    result = true;
}

调用onTouchEvent方法

onTouchEvent方法进行分析,首先会涉及到视图的状态,视图状态有很多,常用的视图状态有如下:

名称 描述
enabled 表示当前视图可用状态。可以通过setEnable方法进行位运算,改变视图的状态。如果对应的为为0,表示不可用,就无法响应onTouch事件,正常情况下(mViewFlags & ENABLED_MASK) == ENABLED
focused 视图是否获得焦点。判断方式(mViewFlags & FOCUSABLE_MASK) == FOCUSABLE,类似于打游戏通过手柄的上下左右键切焦点,requestFocus方法可以改变焦点
pressed 视图是否处于按下状态,按下对象,必须为Clickable。调用setPressed方法来对这一状态进行改变,判断方式(mPrivateFlags & PFLAG_PRESSED) == PFLAG_PRESSED
clickable 视图是否可以点击,CLICKABLE对应的是setClickable函数,在设定点击的响应函数setOnClickListener时,自动会把视图设置为可点击状态

当View处于不可用状态,即·(viewFlags & ENABLED_MASK) == DISABLED·时,函数的返回值为:

return (((viewFlags & CLICKABLE) == CLICKABLE
        || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
        || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);

即只要是可以点击的,不论是长按LONG_CLICKABLE还是短按CLICKABLE,还是内容(用于触控笔按钮或鼠标右键单击)是可点击的CONTEXT_CLICKABLE,都会消耗点击事件,尽管处于不可用状态,View只是不作出相应的响应。

接下来判断视图代理TouchDelegate,用于想要视图具有比其实际视图边界更大的触摸面积。触摸区域被更改的视图称为委托视图。mTouchDelegate的使用机制和mTouchListener,mOnClickListener等接口类似。

if (mTouchDelegate != null) {
    if (mTouchDelegate.onTouchEvent(event)) {
        return true;
    }
}

动作处理

最后是对具体的动作进行处理,当View是可点击视图的时候,最后就一定会返回true,View的CLICKABLE属性状态要分情况看待,实质就是视图是否可以点击,而View的LONG_CLICKABLE属性状态默认是关闭的。

通过switch-case语句,对不同的动作状态进行不同的响应

ACTION_DOWN

初始化长按状态,即把mHasPerformedLongPress赋值为false,尚未执行长按动作;
判断View是否在一个可以滚动的容器中,比如ListView,进行延时,防止当用户实际上是要滑动容器时,出现按下的状态。
如果是在一个这样的容器中,把mPrivateFlagsPREPRESSED标识位置1;通过postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout())函数短时间推延这个动作按下的反馈,设置推延的时长为 ViewConfiguration.getTapTimeout(默认115ms),如果在这个时间段内,该Message没有从消息队列中取出,那么等到时间导到以后就运行CheckForTap类内的run函数,内容包括:

  1. 设定视图的PREPRESSED状态位为0;
  2. 调用setPressed方法,把View的PRESSED状态位设置为1;
  3. 执行checkForLongClick函数,检测View的LONG_CLICKABLE标志位,为1就把长按检测的函数通过postDelayed(mPendingCheckForLongPress,ViewConfiguration.getLongPressTimeout()-delayOffset)加入MessageQueue中,设定延时的时间为长按的检测时间(默认500ms)- 之前延时检测按下状态的时间(默认为115ms),即385ms。

同样如果在这个时间段以内没有从消息队列中取出该Message,执行以下内容:

  1. 执行performLongClick函数,即检测视图的OnLongClickListener接口,如果有定义,就调用onLongClick函数,根据它的返回结果,确定是否把mHasPerformedLongPress状态设为已执行。

当视图不在滚动容器内时,就立即显示按下的反馈,直接调用setPressed方法,并且发出一个检测长按的延迟事件为0毫秒的任务checkForLongClick(0, x, y)

总之就是检测Tap和LongClick:把mPendingCheckForTapmPendingCheckForLongPress对应的run添加到Message队列中

ACTION_MOVE

调用drawableHotspotChanged(x, y),表明View的热点hotspot发生变化,并将变化传播到视图管理的Drawable对象或子视图时。
然后用pointInView方法判断确定给定触摸点(在局部坐标中)是否在视图内,其中视图的边界都扩展了一个最小滑动距离TOUCH_SLOP的大小。如果移出了范围:

  1. removeTapCallback函数,把PFLAG_PREPRESSED标志位置0,并把之前CheckForTap对象mPendingCheckForTap要延时执行的Runnable通过removeCallbacks函数从消息队列中取消(如果还未执行的话);
  2. 查看PRESSED状态位,为1,说明已经过了检测Tap的115ms,第一条中的Runnable已经执行了,要移除在run函数中添加的检测长按的CheckForLongPress类对象mPendingCheckForLongPress;并且执行setPressed,把PRESSED标志位置为0。

即只要用户移出了对应的视图的坐标范围,就将所有关于轻触(tap)和长按(long press)的状态全部取消。

ACTION_UP:

  1. 动作结束了,对之前的所有标识进行一个总的判断,查看PREPRESSEDPRESSED状态位,不管哪一个是真,都进入下一阶段;
  2. 为视图请求焦点,并且进入触摸模式。如果View可以获得焦点,并且还没有获得焦点,就请求焦点;
  3. 把之前为预按下状态(PFLAG_PREPRESSED)的设置为按下,确保用户可以看到按下状态的出现。即如果 prepressed 值为true,调用 setPressed(true, x, y),把 PRESSED**标志位设定为1,对应于下边的第6点;
  4. 如果表示长按状态的mHasPerformedLongPress为false,并且忽略下一次ACTION_UP事件的mIgnoreNextUpEvent状态标识为false,就移除长按的检测,因为手势已经到此结束了,不可能再有长按了;
  5. 判断mPerformClick,如果为null,初始化一个实例,该类实现了一个Runable接口,然后调用post,通过异步处理Handler发送run函数到消息队列尾部,如果添加Message失败则直接执行performClick函数,确保执行,不直接调用performClick函数,可以让View的其他视觉状态在点击动作开始之前更新。
  6. 如果之前获取的prepressed值为true,64毫秒(ViewConfiguration.getPresedStateDuration)后执行UnsetPressedState类对象mUnsetPresedState,否则立即执行mUnsetPresedState;最后无论如何mUnsetPresedState.run()都会执行,其内部调用了·setPresed(false)·,把的PRESSED标志位重置为0,这样实际上是为了保证之前处于预按下状态的View,变为按下状态,有一个足够的延时(默认为64ms),来让用户观察到。
  7. 最后调用·removeTapCallback·函数,目的是移除PFLAG_PREPRESSED状态位,并且撤销在消息队列中对应的tap延时执行内容 。

总结

这就是Android的事件分发机制的主要流程,关键方法如下

方法 调用位置 描述
dispatchTouchEvent A, VG, V 分发事件到子视图
onInterceptTouchEvent VG 在传递到子视图前拦截事件
onTouchEvent V 处理触摸事件

A代表Activity,VG代表ViewGroup, V 代表View

上述内容参照了网上一些博客的描述。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容