前言
最近帮公司面试 Android 岗位,面试的同学大部分具有两年以上的工作经验,但发现很多同学对触摸事件的分发都不是很熟悉,有的可能还能照本宣科地说个七七八八,有的却完全不熟悉,这让我感觉到诧异,因为我认为这 Android 中非常基础的知识,不仅要熟悉其中的分发流程,还要能实现简单的涉及手势处理的自定义控件。所以,这也是这篇文章的目的,希望能通过一些触摸事件处理的实际例子,来加深对触摸事件分发的理解,同时能实现相关需求。
触摸事件简单回顾
我们都知道触摸事件的分发严格意义上讲,是从 Activity 开始进行分发,但一般我们谈论的,只从 ViewGroup 开始分发。
上面的图只是粗略地画出触摸事件的分发流程,从 ViewGroup 的 dispatchTouchEvent
开始分发,先判断 ViewGroup 的 onInterceptTouchEvent
是否拦截,同时这里也可以调用 ViewGroup 的 requestDisallowInterceptTouchEvent
让 ViewGroup 不调用 onInterceptTouchEvent
,如果事件被拦截,则调用 ViewGroup 的超类即 View 的 dispatchTouchEvent
,反之,则调用子视图的 dispatchTouchEvent
,注意:这里我们做了简单处理,假设当前 ViewGroup 的子视图为 View,如果子视图为 ViewGroup,那么还是先执行 ViewGroup 的 dispatchTouchEvent
。
最终都会调用到 View 的 onTouchEvent
中,这个方法是真正处理触摸事件的,一般如果我们需要自己处理触摸事件,也是在这个方法中处理。
手势判定
上面我们简单回顾了触摸事件分发,具体细节可以看下源码。其实上面所讲的分发流程,很多同学都能说出个大概,但就是在动手写的时候,不知道如何入手。
当我们在日常开发中,如果遇到不知道怎么实现的需求时,最直接的方法就是阅读他人的源码,不管是个人的库也好,Google 提供的库也好,设计良好的源码抵过看一大堆的博客。在实现需要处理触摸事件的需求也是一个道理,Android SDK 已经提供了 ListView,RecyclerView,ScrollView 等等涉及拖动处理的系统控件,所以我们可以从阅读这类控件的源码入手,看看系统控件是如何处理触摸事件的。这里我们选择 ScrollView 来简单分析下:
ScrollView 继承于 FrameLayout,属于 ViewGroup 控件,所以有 onInterceptTouchEvent
因为篇幅有限,所以我们会省略去无关紧要的代码,各位同学可以对照着源码进行分析。
源码部分基于 Android 26
onInterceptTouchEvent
1 | if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) { |
用 mIsBeingDragged
变量来保存当前是否已经开始进行拖动手势,这个后面会讲到,同时当前分发事件类型为 ACTION_MOVE
,那么直接返回 true,即拦截事件向子视图进行分发。这个是一个快捷的判断,省去了后面再进行一系列的判断,这个是个小技巧,我们在实现自定义控件的时候也可以用上。接下来的分析,为了更清晰,我们分为不同类型的 Touch Action 进行阅读。
TOUCH_DOWN
1 | if (!inChild((int) ev.getX(), (int) y)) { |
这段代码很简单,如果触摸事件没有作用于子视图范围内,那么就不处理,同时释放速度跟踪器,这个后面会讲到,一般用于 fling 手势的判定。
1 | mLastMotionY = y; |
这段代码主要是用于初始化判定之前的资源,比如 mLastMotionY
记录按下时的坐标信息,mActivePointerId
记录当前分发触摸事件的手指 id,这个一般用于多指的处理,initOrResetVelocityTracker
初始化速度跟踪器,同时使用 addMovement
记录当前触摸事件信息,mScroller
是一般用于 fling 手势处理,这里的作用是处理上一次的 fling,startNestedScroll
则是嵌套滚动机制的知识了,嵌套滚动机制也不难理解,但这里我们先不涉及,相信理解基础的触摸事件知识后,这个只要稍微阅读下源码,就能理解的,说句题外话,虽然嵌套滚动很容易理解,作用却非常大,基本解决了大部分的滑动冲突场景。
TOUCH_MOVE
1 | final int activePointerId = mActivePointerId; |
这段代码先使用我们在 TOUCH_DOWN
中记录的 mActivePointerId
进行是否为有效的判断,如果有效,则通过 findPointerIndex
获取作用手指 id 的下标,记录为 pointerIndex
,为什么要获取这个值,我们知道现在的手机屏幕都是支持多指触摸的,所以我们需要根据某个按下的手指的触摸信息来进行处理。yDiff
是滑动的距离,mTouchSlop
则是 SDK 定义的可作为判定是否开始进行拖动的距离常量,可以通过 ViewConfiguration 的 getScaledTouchSlop
获取,如果大于这个值,我们可以认为开始了拖动的手势。 getNestedScrollAxes
这个同样是用于嵌套滚动机制的,可以略过。如果开始了拖动手势,mIsBeingDragged
标记为 true
,同样使用速度跟踪器记录信息,这里还会调用 ViewParent 的 requestDisallowInterceptTouchEvent
,防止父视图拦截了事件,即 onInterceptTouchEvent
TOUCH_CANCEL && TOUCH_UP
1 | mIsBeingDragged = false; |
一般我们都会在 TOUCH_CANCEL
和 TOUCH_UP
这两个类型的触摸事件分发中,进行一些释放资源的操作,比如 mIsBeingDragged
设置为 false
,释放速度跟踪器等等
TOUCH_UP
是所有的手指(多指触摸)抬起时分发的事件,这个比较好理解,而TOUCH_CANCEL
则是触摸取消事件类型,一般什么时候会分发这个事件呢?举个例子,如果某个子视图已经消费了TOUCH_DOWN
,即在这个事件分发时,向父视图传递了true
的返回值,那么一般情况下,父视图不会再拦截接下来的事件,比如ACTION_MOVE
等,但是如果父视图在这种情况下,还拦截了事件传递,即在onInterceptTouch
中返回了true
,那么在 ViewGroup 的dispatchTouchEvent
中会给已经确认消费事件的子视图分发一个TOUCH_CANCEL
的事件,具体可以阅读源码。
ACTION_POINTER_UP
这个为多指触摸时,某个手指抬起时分发的事件。
1 | final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> |
这段代码处理的是,当某个手指抬起时,而这个手指刚好是我们当前使用的,则重新初始化资源
小结
我们可以简单总结下,onInterceptTouchEvent
所进行的处理,即在 TOUCH_DOWN
资源初始化,TOUCH_MOVE
判断是否开始拖动手势,TOUCH_CANCEL
&& TOUCH_UP
中进行资源释放。这里涉及了多指触摸的处理。
onTouchEvent
onTouchEvent
要比 onInterceptTouch
的逻辑更复杂,因为这个方法是用于真正的消费触摸事件。同样的,我们只关心核心代码,略去无关紧要的代码片段。
TOUCH_DOWN
1 | if (getChildCount() == 0) { |
同样的,onTouchEvent
在 TOUCH_DOWN
事件分发中,主要是进行资源初始化,同时也处理上一次的 fling 任务,比如调用 Scroller 的 abortAnimation
,如果 Scroller 还没结束 fling 计算,则中止处理。
TOUCH_MOVE
1 | final int activePointerIndex = ev.findPointerIndex(mActivePointerId); |
这段代码同样会进行多指处理,获取指定手指的触摸事件信息。mIsBeingDragged
为 false
,同时会再进行一次拖动手势的判定,判定逻辑和 onInterceptTouchEvent
中类似,如果 mIsBeingDragged
为 true
,则开始进行真正的逻辑处理。
EdgeEffect 是用于拖动时,边缘的阴影效果,具体使用可以参考源码
TOUCH_UP
1 | if (mIsBeingDragged) { |
当手指全部抬起时,可以使用速度跟踪器进行 fling 手势的判定,同时释放资源。通过 getYVelocity
获取速度,在判断是否可以作为 fling 手势处理,mMaximumVelocity
是处理的最大速度,mMinimumVelocity
是处理的最小速度,这两个值同样可以通过 ViewConfiguration 的 getScaledMaximumFlingVelocity
和 getScaledMinimumFlingVelocity
获取。一般情况对 fling 的处理是通过 Scroller 进行处理的,因为这里涉及复杂的数学知识,而 Scroller 可以帮我们简化这里的操作,使用如下:
1 | int height = getHeight() - mPaddingBottom - mPaddingTop; |
通过传递当前拖动手势速度值来调用 fling
进行处理,然后在 computeScrollOffset
方法中,进行真正的滚动处理:
1 | public void computeScroll() { |
首先我们要知道 Scroller 并不会为我们进行滚动处理,它只是提供了计算的模型,通过调用 computeScrollOffset
进行计算,如果返回 true
,表示计算还没结束,然后通过 getCurrX
或 getCurrY
获取计算后的值,最后进行真正的滚动处理,比如调用 scrollTo
等等,这里需要注意的是,需要调用 invalidate
来确保进行下一次的 computeScroll
调用,这里使用的 postInvalidateOnAnimation
其作用是类似的。
TOUCH_CANCEL
1 | if (mIsBeingDragged && getChildCount() > 0) { |
同样的,一般我们都会在 TOUCH_CANCEL
中释放资源。
ACTION_POINTER_DOWN
当有新的手指按下时分发的事件
1 | final int index = ev.getActionIndex(); |
以新按下的手指的信息重新计算
ACTION_POINTER_UP
这里的处理和 onInterceptTouch
一致
小结
onTouchEvent
和 onInterceptTouchEvent
处理有些相似,主要是在 TOUCH_MOVE
中在判定为拖动手势后进行真正的业务逻辑处理,同时在 TOUCH_UP
中根据速度跟踪器的获取的速度,判定是否符合 fling 手势,如果符合,则使用 Scroller 进行计算。
总结
在分析完 ScrollView 的触摸事件处理,我们应该对事件处理有个基本理解,可以按照这个思路去分析其他的类似的系统控件,比如 NestedScrollView
、RecyclerView
等等,我们可以发现处理的思路基本一致,那我们是不是可以将这些判定逻辑封装下呢?是的,并且系统已经提供 GestureDetector 来进行手势的判定,我们只需要在相应的手势回调方法中进行我们的业务逻辑即可。还有更强大的 ViewDragHelper
,但不管怎样,只要能理解好触摸事件分发,对工具类的熟练使用就不在话下。
实战
理论说的再多,也是纸上谈兵,只有真正去实践,才能熟练掌握。因为业务需求或者兴趣爱好,我写了以下两个自定义控件,正好一个是纵向滑动和一个是横向滑动,效果如下:
详细的代码分析我们就不进行了,因为阅读源码是最快的方式,第一个是滚轮控件,第二个是之前参加活动的仿薄荷健康的卷尺控件。在这里我们只分析部分代码:
滚轮控件
在前面手势判定中的分析中,我们提到在 onTouchEvent
判定拖动手势成功后,进行真正的业务逻辑处理,在这个控件中也是一样的:
1 | if (mIsBeingDragged) { |
在每次 TOUCH_MOVE
事件分发时,计算与 TOUCH_DOWN
时记录的位置信息的差值,保存为 mDistanceY
,并且在 onDraw
中使用这个值对 Canvas 进行位移,绘制新位置的 UI。
卷尺控件
拖动距离的计算与滚轮控件一样,只是记录为 mOffsetLeft
而已,同时两个控件都有在 onTouchEvent
的 ACTION_UP
事件分发中,处理 fling 手势。不过卷尺控件有使用 EdgeEffect 处理边缘效果,有兴趣的同学可以看下。
结语
文章的主要目的并不在于教会怎么去处理触摸事件的分发,只是希望通过这个例子,大家能养成阅读源码的习惯,不管是系统 SDK 也好,第三方库也好,只要把核心知识掌握,就能熟练使用各种现成的工具库,并且达到举一反三的效果。
但是理论知识再多也是纸上谈兵,最重要的是实践,具体实践可以这样做:先理解好触摸事件分发流程,然后选择一个控件,可以是系统控件,也可以是其他控件,只要涉及触摸事件处理就行,进行阅读,然后手动实现一个相反方向滚动的控件,比如,你阅读的是纵向滑动的控件,那么就实现一个横向滑动的控件。这个自定义控件需要实现以下效果:
- 最基本的拖动手势处理
- fling 效果实现
- 如果可以,再实现边缘效果
感谢大家的阅读。