了解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判断即可。
1 | // MotionEvent event |
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界面的响应。
1 | private static Handler handler = new Handler(Looper.getMainLooper()); |
事件分发流程
事件分发到View前经过的几个关键方法:
1 | ViewRootImpl.ViewPostImeInputStage.processPointerEvent |
View中的关键函数调用关系如下:
1 | ViewGroup.dispatchTouchEvent { |
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用于计算滑动速度。几个关键方法:
1 | // 静态方法,创建实例 |
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如下:
1 | event______: ACTION_DOWN, xy = (370.5, 1147.1) |