摘要
本文梳理Android事件分发涉及到的各种知识点,包括MotionEvent、事件分发流程、ViewConfiguration、VelocityTracker等,并给出了几个典型的实际案例。
了解MotionEvent
MotionEvent.class
自身并不实际包含Action相关的信息,只包含了一个mNativePtr,指向JNI层实际包含所有Event信息的Object。
Pointer
- Pointer指多点触控中的一个点,通常就是一根手指。
- 一般第一个按下的Pointer即为Primary Pointer,其他的为Non-Primary Pointer。
- PointerIndex,即Pointer的Index,用于区分不同的Pointer。
Action
View在响应一次用户操作时,会接收到一个事件流,以ACTION_DOWN开始,中间有若干个ACTION_MOVE、ACTION_POINTER_DOWN、ACTION_POINTER_UP,最后以ACTION_UP或者ACTION_CANCEL结束。
4个基本事件如下:
- ACTION_DOWN:第一个Pointer按下。获取到的是最初始状态下的信息。此时View一般会设置一些标志位,表明自己被按下了,开始处理用户事件。如果有设置selector,此时selector也会变成pressed状态。
- ACTION_MOVE:有一个Pointer移动。由于事件分发有时间间隔(包括可能发生卡顿),实际获取到的数据是离当前最近的时间点的数据。通常在ACTION_MOVE事件发生时处理滑动操作。
- ACTION_UP:最后一个Pointer释放。通常在此时处理点击操作,或者作为滑动操作的终止条件,并开始执行惯性滑动(Fling)过程。
- ACTION_CANCEL:操作被取消,通常是因为事件被其他组件处理了。收到ACTION_CANCEL的组件应该及时清除按下状态,且不作出事件响应。具体场景可以参考后文的实际案例。
多点控触新增的Action:
- ACTION_POINTER_DOWN:一个Non-Primary Pointer按下
- ACTION_POINTER_UP:一个Non-Primary Pointer释放
事件API以及单点、多点触控的兼容问题
1、早期Android只支持单点触控,View中通过getAction()
获取int型的事件(Action),然后用switch-case判断即可。
// MotionEvent event
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_UP:
}
2、高版本Android系统中增加了多点触控机制,如果直接用getAction()
获取int型action值,其中最低8位即0-7位用于存储原有的Action,8-15位用于在ACTION_POINTER_DOWN/UP
事件发生时存储PointerIndex
。系统提供了新的API,一般应该使用getActionMasked()
获取低8位的Action部分,通过getActionIndex()
获取8-15位的PointerIndex
。
3、多点触控机制可以兼容单点触控的View组件。也就是说,在高版本Android系统提供了多点触控机制之后,原有的单点触控组件仍然能正常运行。原因是原有的4个基本事件发生时,8-15位全为0,并没有PointerIndex
,用getAction()
和getActionMasked()
得到的值相同。
坐标、压力、大小
getX()
,getY()
可获取MotionEvent相对当前View的坐标getRawX()
,getRawY()
可获取MotionEvent相对屏幕的坐标getPressure()
,getSize()
等可获取压力、大小等信息,需要硬件支持
Pointer相关API
- 通过
MotionEvent.getPointerCount()
,可以获取当前有几个Pointer处于按下状态。 - 通过
getPointerId(int pointerIndex)
,findPointerIndex(int pointerId)
可以互相转换PointerIndex和PointerId。 getX(int pointerIndex)
,getY(int pointerIndex)
等方法可以获取指定PointerIndex的坐标、压力、大小等信息。
生成精确的MotionEvent用于调试
开发过程中,常需要调试交互组件,但手工操作比较麻烦,耗时耗力,精度低,可能满足不了需要。这时就可以考虑使用代码自动产生精确的MotionEvent序列用于调试。
例如可以用下面的方法,产生精确的滑动事件序列,直接调用Activity.dispatchTouchEvent
注入事件,观察UI界面的响应。
private static Handler handler = new Handler(Looper.getMainLooper());
// 向Activity分发移动事件 ACTION_DOWN --> MOVE --> MOVE --> ... --> MOVE --> UP
public static void moveAction(final Activity activity, int startX, int startY, int stepX, int stepY, int stepT, int eventCount) {
if (eventCount < 3) return;
final long downTime = System.currentTimeMillis();
int offsetTime = 0;
for (int i = 0; i < eventCount; i++) {
final int action;
if (i == 0) {
action = MotionEvent.ACTION_DOWN;
} else if (i == eventCount - 1) {
action = MotionEvent.ACTION_UP;
} else {
action = MotionEvent.ACTION_MOVE;
}
final long eventTime = downTime + offsetTime;
final int x = startX;
final int y = startY;
final int index = i;
handler.postDelayed(new Runnable() {
@Override
public void run() {
Log.i("Event", String.format("----> send event(%d): xy = (%d, %d)", index, x, y));
activity.dispatchTouchEvent(MotionEvent.obtain(downTime, eventTime, action, x, y, 0));
}
}, offsetTime);
startX += stepX;
startY += stepY;
offsetTime += stepT;
}
}
事件分发流程
事件分发到View前经过的几个关键方法:
ViewRootImpl.ViewPostImeInputStage.processPointerEvent
PhoneWindow.DecoreView.dispatchTouchEvent
Activity.dispatchTouchEvent
ViewGroup.dispatchTouchEvent
View中的关键函数调用关系如下:
ViewGroup.dispatchTouchEvent {
ViewGroup.onInterceptTouchEvent
ViewGroup.dispatchTransformedTouchEvent {
child.dispatchTouchEvent
super/View.dispatchTouchEvent {
View.OnTouchListener.onTouch
View.onTouchEvent {
View.TouchDelegate.onTouchEvent
View.performClick
}
}
}
}
requestDisallowInterceptTouchEvent
当子View不希望父View拦截事件时,可以调用父View的requestDisallowInterceptTouchEvent方法。
DescendantFocusability
DescendantFocusability用于设置嵌套View的焦点获取优先顺序,通常只有获取的焦点的View才能响应点击事件。
- blocksDescendants
- beforeDescendants
- afterDescendants
AbsListView嵌套AbsListView、CheckBox、Button、ImageButton、设置了OnClickListener的View,焦点默认分发给子View,导致OnItemClickListener无效。此时可以将AbsListView设置成blocksDescendants。
TouchDelegate
TouchDelegate可扩大一个View的触摸响应范围,使其可以响应超过自身大小范围的事件。
子View在onLayout获取尺寸后,给Parent设置一个TouchDelegate,即可扩展自身点击区域。这个区域不能超过Parent的点击区域;且每个Parent只能设置一个TouchDelegate,指定给一个子View扩展点击区域。
更复杂的需要,可以通过覆写TouchDelegate,或者覆写Parent的事件处理方法实现。
VelocityTracker
VelocityTracker用于计算滑动速度。几个关键方法:
// 静态方法,创建实例
VelocityTracker.obtain();
// 回收对象
VelocityTracker.recycle();
// 清除事件
VelocityTracker.clear();
// 添加一个MotionEvent
VelocityTracker.addMovement(MotionEvent ev);
// 计算速度,units通常取1000,表示计算每秒移动多少个像素点
VelocityTracker.computeCurrentVelocity(int units, float maxVelocity);
// 获取X、Y方向滑动速度
VelocityTracker.getXVelocity();
VelocityTracker.getYVelocity();
ViewConfiguration
ViewConfiguration接口中定义了一系列和View操作相关的阈值。
TouchSlop
当用户点击屏幕上的一个View时,除了ACTION_DOWN
和ACTION_UP
,一般情况下,由于手指操作并不十分精确,中间可能会产生若干个ACTION_MOVE
,且ACTION_DOWN
和ACTION_UP
事件的坐标通常并不完全相同。
如何区分用户是点击还是滑动操作呢?Android中使用TouchSlop来解决这个问题。当移动的距离小于等于TouchSlop时,视为点击操作;大于TouchSlop,则视为滑动操作。这里的距离,可能是X方向、Y方向,视具体的View组件而定。
在Android源码中,一般TouchSlop被定义为8dp对应的像素点,实际根据不同手机ROM而定。
PagingTouchSlop
和TouchSlop类似的还有一个PagingTouchSlop,这个通常用于ViewPager中判断用户滑动距离是否可以视为翻页操作。
其他
ViewConfiguration还定义了其他一些值,例如按下多长时间可以认为是长按操作,速度达到多大可以视为快速滑动(Fling)等。
GestureDetector
GestureDetector提供了一套比较简单的接口,实现常用手势检测。
案例一:自己实现一个OnClickListener
覆写View的onTouchEvent,或设置OnTouchListener。
方案一:
ACTION_DOWN
时记录下初始坐标mStartX
,mStartY
。ACTION_UP
时获取事件坐标x
,y
,分别判断x、y两个方向相对初始坐标mStartX
,mStartY
的位移均没有超过TouchSlop,则可视为点击事件,此时即可触发OnClickListener。如果超过了TouchSlop则视为滑动事件,不会触发点击事件。
方案二:
方案一有个小问题,如果用户手指在View组件上滑动了一段距离然后又滑动回来然后释放,且ACTION_DOWN和ACTION_UP的坐标恰好接近,此时还是会被视为点击操作。改进的方案二如下:
ACTION_DOWN
时记录下初始坐标mStartX
,mStartY
,并设置标志位mIsBeingDragged为false。- 每个
ACTION_MOVE
和ACTION_UP
事件生成时判断位移相对初始坐标是否超出TouchSlop,如果超过了则设置标志位mIsBeingDragged为true。 ACTION_UP
时判断标志位mIsBeingDragged如果为false,则视为点击事件。
案例二:ScrollView嵌套Button的事件分发
ScrollView嵌套一个Button。
场景1
- 当手指按下按钮时,按钮处于按下状态;
- 手指释放,Button被点击。
场景2
- 当手指按下按钮时,按钮处于按下状态;
- 手指开始进行滑动,可以观察到,按钮的按下状态消失,ScrollView开始滚动。
这个两个场景中的事件分发过程如下:
ACTION_DOWN
被传递给ScrollView.dispatchTouchEvent
,然后调用ScrollView.onInterceptTouchEvent
,返回值为false。于是ScrollView会把ACTION_DOWN
分发给Button。- Button接收到事件后,设置自己处于按下状态,于是selector会展示成selected状态。
- 手指开始滚动,连续产生多个
ACTION_MOVE
,其垂直方向相对ACTION_DOWN
的位移没有达到TouchSlop,事件经过ScrollView.dispatchTouchEvent
,ScrollView.onInterceptTouchEvent
,然后被分发给Button。 - 对于场景1,在
ACTION_MOVE
后,产生了一个ACTION_UP
事件,并被分发给Button,于是Button.performClick
被执行,从而OnClickListener
被调用。流程结束。 - 对于场景2,手指继续滑动直到某一次
ACTION_MOVE
位移达到TouchSlop,ScrollView.onInterceptTouchEvent
会设置mIsBeingDragged=true
并返回true,此时ScrollView.dispatchTouchEvent
判断发现之前已经把事件分发给Button了,于是给Button分发一个ACTION_CANCEL
事件,同时把这个ACTION_MOVE
分发给自己的ScrollView.onTouchEvent
- Button接收到
ACTION_CANCEL
事件后,取消按下状态,且不触发OnClickListener
。 ScrollView.onTouchEvent
接收到ACTION_MOVE
事件后,开始滚动。- 由于
mIsBeingDragged==true
,之后的ACTION_MOVE
、ACTION_UP
都会被发送给ScrollView.onTouchEvent
。 ACTION_UP
事件发生时,ScrollView会获取VelocityTracker记录的滚动速度,然后利用Scroller执行Fling过程,即惯性滚动。流程结束。
场景2输出的Log如下:
event______: ACTION_DOWN, xy = (370.5, 1147.1)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onInterceptTouchEvent
m_LogScroll: onInterceptTouchEvent return false
m_LogButton: dispatchTouchEvent
m_LogButton: onTouchEvent
m_LogButton: onTouchEvent return true
m_LogButton: dispatchTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (370.5, 1140.4)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onInterceptTouchEvent
m_LogScroll: onInterceptTouchEvent return false
m_LogButton: dispatchTouchEvent
m_LogButton: onTouchEvent
m_LogButton: onTouchEvent return true
m_LogButton: dispatchTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (370.5, 1124.0)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onInterceptTouchEvent
m_LogScroll: onInterceptTouchEvent return true
event______: ACTION_CANCEL, xy = (370.5, 1124.0)
m_LogButton: dispatchTouchEvent
m_LogButton: onTouchEvent
m_LogButton: onTouchEvent return true
m_LogButton: dispatchTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (372.5, 1088.7)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (375.5, 1056.0)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (378.2, 1038.7)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (377.5, 1025.2)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (377.5, 1017.2)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_MOVE, xy = (377.5, 1017.2)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
event______: ACTION_UP, xy = (377.5, 1017.2)
m_LogScroll: dispatchTouchEvent
m_LogScroll: onTouchEvent
m_LogScroll: onTouchEvent return true
m_LogScroll: dispatchTouchEvent return true
m_LogScroll:
最后,欢迎扫码关注微信公众号。程序员同行学习交流,聊天交友,国内外名企求职内推(微软 / 小冰 / Amazon / Shopee / Coupang / ATM / 头条 / 拼多多等),可加我微信 jzj2015 进技术群(备注进技术群,并简单自我介绍)。

本文由jzj1993原创,转载请注明来源:https://www.paincker.com/android-motion-event
(标注了原文链接的文章除外)
暂无评论