Android触摸事件总结
前言
最近公司面试 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。
(因为篇幅原因,源码自行查阅API26)
onInterceptTouchEvent
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
return true;
}
用 mIsBeingDragged 变量来保存当前是否已经开始进行拖动手势,这个后面会讲到,同时当前分发事件类型为 ACTION_MOVE,那么直接返回 true,即拦截事件向子视图进行分发。这个是一个快捷的判断,省去了后面再进行一系列的判断,这个是个小技巧,我们在实现自定义控件的时候也可以用上。接下来的分析,为了更清晰,我们分为不同类型的 Touch Action 进行阅读。
TOUCH_DOWN
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}
这段代码很简单,如果触摸事件没有作用于子视图范围内,那么就不处理,同时释放速度跟踪器,这个后面会讲到,一般用于 fling 手势的判定。
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);
initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
mScroller.computeScrollOffset();
mIsBeingDragged = !mScroller.isFinished();
startNestedScroll(SCROLL_AXIS_VERTICAL);
这段代码主要是用于初始化判定之前的资源,比如 mLastMotionY 记录按下时的坐标信息,mActivePointerId 记录当前分发触摸事件的手指 id,这个一般用于多指的处理,initOrResetVelocityTracker 初始化速度跟踪器,同时使用 addMovement 记录当前触摸事件信息,mScroller 是一般用于 fling 手势处理,这里的作用是处理上一次的 fling,startNestedScroll 则是嵌套滚动机制的知识了,嵌套滚动机制也不难理解,但这里我们先不涉及,相信理解基础的触摸事件知识后,这个只要稍微阅读下源码,就能理解的,说句题外话,虽然嵌套滚动很容易理解,作用却非常大,基本解决了大部分的滑动冲突场景。
TOUCH_MOVE
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
break;
}
final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
break;
}
final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
我们在 TOUCH_DOWN 中记录的 mActivePointerId 进行是否为有效的判断,如果有效,则通过 findPointerIndex 获取作用手指 id 的下标,记录为 pointerIndex ,为什么要获取这个值,我们知道现在的手机屏幕都是支持多指触摸的,所以我们需要根据某个按下的手指的触摸信息来进行处理。yDiff 是滑动的距离,mTouchSlop 则是 SDK 定义的可作为判定是否开始进行拖动的距离常量,可以通过 ViewConfiguration 的 getScaledTouchSlop 获取,如果大于这个值,我们可以认为开始了拖动的手势。 getNestedScrollAxes 这个同样是用于嵌套滚动机制的,可以略过。如果开始了拖动手势,mIsBeingDragged 标记为 true,同样使用速度跟踪器记录信息,这里还会调用 ViewParent 的 requestDisallowInterceptTouchEvent,防止父视图拦截了事件,即 onInterceptTouchEvent。
TOUCH_CANCEL && TOUCH_UP
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
recycleVelocityTracker();
stopNestedScroll();
一般我们都会在 TOUCH_CANCEL 和 TOUCH_UP 这两个类型的触摸事件分发中,进行一些释放资源的操作,比如 mIsBeingDragged 设置为 false,释放速度跟踪器等等。
TOUCH_UP 是所有的手指(多指触摸)抬起时分发的事件,这个比较好理解,而 TOUCH_CANCEL 则是触摸取消事件类型,一般什么时候会分发这个事件呢?举个例子,如果某个子视图已经消费了 。
TOUCH_DOWN,即在这个事件分发时,向父视图传递了 true 的返回值,那么一般情况下,父视图不会再拦截接下来的事件,比如 ACTION_MOVE 等,但是如果父视图在这种情况下,还拦截了事件传递,即在 onInterceptTouch 中返回了 true,那么在 ViewGroup 的 dispatchTouchEvent 中会给已经确认消费事件的子视图分发一个 TOUCH_CANCEL 的事件,具体可以阅读源码。
ACTION_POINTER_UP(这个为多指触摸时,某个手指抬起时分发的事件)
final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >>
MotionEvent.ACTION_POINTER_INDEX_SHIFT;
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mLastMotionY = (int) ev.getY(newPointerIndex);
mActivePointerId = ev.getPointerId(newPointerIndex);
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
}
这段代码处理的是,当某个手指抬起时,而这个手指刚好是我们当前使用的,则重新初始化资源。
小结
我们可以简单总结下,onInterceptTouchEvent 所进行的处理,即在 TOUCH_DOWN 资源初始化,TOUCH_MOVE 判断是否开始拖动手势,TOUCH_CANCEL && TOUCH_UP 中进行资源释放。这里涉及了多指触摸的处理。
onTouchEvent
onTouchEvent 要比 onInterceptTouch 的逻辑更复杂,因为这个方法是用于真正的消费触摸事件。同样的,我们只关心核心代码,略去无关紧要的代码片段。
TOUCH_DOWN
if(getChildCount() ==0) {
returnfalse;
}
if((mIsBeingDragged = !mScroller.isFinished())) {
finalViewParent parent = getParent();
if(parent !=null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
if(!mScroller.isFinished()) {
mScroller.abortAnimation();
}
mLastMotionY = (int) ev.getY();
mActivePointerId = ev.getPointerId(0);
同样的,onTouchEvent 在 TOUCH_DOWN 事件分发中,主要是进行资源初始化,同时也处理上一次的 fling 任务,比如调用 Scroller 的 abortAnimation,如果 Scroller 还没结束 fling 计算,则中止处理。
TOUCH_MOVE
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
if (activePointerIndex == -1) {
break;
}
final int y = (int) ev.getY(activePointerIndex);
int deltaY = mLastMotionY - y;
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
// 嵌套滚动处理
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
if (mIsBeingDragged) {
/// 业务逻辑
}
这段代码同样会进行多指处理,获取指定手指的触摸事件信息。mIsBeingDragged 为 false,同时会再进行一次拖动手势的判定,判定逻辑和 onInterceptTouchEvent 中类似,如果 mIsBeingDragged为 true,则开始进行真正的逻辑处理。(EdgeEffect 是用于拖动时,边缘的阴影效果,具体使用可以参考源码。)
TOUCH_UP
if(mIsBeingDragged) {
finalVelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
intinitialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);
if((Math.abs(initialVelocity) > mMinimumVelocity)) {
flingWithNestedDispatch(-initialVelocity);
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
当手指全部抬起时,可以使用速度跟踪器进行 fling 手势的判定,同时释放资源。通过 getYVelocity获取速度,在判断是否可以作为 fling 手势处理,mMaximumVelocity 是处理的最大速度,mMinimumVelocity 是处理的最小速度,这两个值同样可以通过 ViewConfiguration的 getScaledMaximumFlingVelocity 和 getScaledMinimumFlingVelocity 获取。一般情况对 fling 的处理是通过 Scroller 进行处理的,因为这里涉及复杂的数学知识,而 Scroller 可以帮我们简化这里的操作,使用如下:
intheight = getHeight() - mPaddingBottom - mPaddingTop;
intbottom = getChildAt(0).getHeight();
mScroller.fling(mScrollX, mScrollY,0, velocityY,0,0,0,
Math.max(0, bottom - height),0, height/2);
postInvalidateOnAnimation();
通过传递当前拖动手势速度值来调用 fling 进行处理,然后在 computeScrollOffset 方法中,进行真正的滚动处理:
publicvoidcomputeScroll(){
if(mScroller.computeScrollOffset()) {
// 逻辑处理
intx = mScroller.getCurrX();
inty = mScroller.getCurrY();
postInvalidateOnAnimation();
}
}
首先我们要知道 Scroller 并不会为我们进行滚动处理,它只是提供了计算的模型,通过调用 computeScrollOffset 进行计算,如果返回 true,表示计算还没结束,然后通过 getCurrX 或 getCurrY 获取计算后的值,最后进行真正的滚动处理,比如调用 scrollTo 等等,这里需要注意的是,需要调用 invalidate 来确保进行下一次的 computeScroll 调用,这里使用的 postInvalidateOnAnimation 其作用是类似的。
TOUCH_CANCEL
if(mIsBeingDragged && getChildCount() >0) {
if(mScroller.springBack(mScrollX, mScrollY,0,0,0, getScrollRange())) {
postInvalidateOnAnimation();
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
同样的,一般我们都会在 TOUCH_CANCEL 中释放资源。
ACTION_POINTER_DOWN
当有新的手指按下时分发的事件
finalintindex = ev.getActionIndex();
mLastMotionY = (int) ev.getY(index);
mActivePointerId = ev.getPointerId(index);
以新按下的手指的信息重新计算
ACTION_POINTER_UP
这里的处理和 onInterceptTouch 一致
小结
onTouchEvent 和 onInterceptTouchEvent 处理有些相似,主要是在 TOUCH_MOVE 中在判定为拖动手势后进行真正的业务逻辑处理,同时在 TOUCH_UP 中根据速度跟踪器的获取的速度,判定是否符合 fling 手势,如果符合,则使用 Scroller 进行计算。
总结
在分析完 ScrollView 的触摸事件处理,我们应该对事件处理有个基本理解,可以按照这个思路去分析其他的类似的系统控件,比如 NestedScrollView、RecyclerView 等等,我们可以发现处理的思路基本一致,那我们是不是可以将这些判定逻辑封装下呢?是的,并且系统已经提供 GestureDetector 来进行手势的判定,我们只需要在相应的手势回调方法中进行我们的业务逻辑即可。还有更强大的 ViewDragHelper ,但不管怎样,只要能理解好触摸事件分发,对工具类的熟练使用就不在话下。
实战
理论说的再多,也是纸上谈兵,只有真正去实践,才能熟练掌握。因为业务需求或者兴趣爱好,我写了以下两个自定义控件,正好一个是纵向滑动和一个是横向滑动,效果如下:
滚轮控件 ↑
分析部分代码:
滚轮控件
在前面手势判定中的分析中,我们提到在 onTouchEvent 判定拖动手势成功后,进行真正的业务逻辑处理,在这个控件中也是一样的:
if(mIsBeingDragged) {
mDistanceY += mLastY - moveY;
postInvalidateOnAnimation();
}
在每次 TOUCH_MOVE 事件分发时,计算与 TOUCH_DOWN 时记录的位置信息的差值,保存为 mDistanceY,并且在 onDraw 中使用这个值对 Canvas 进行位移,绘制新位置的 UI。
卷尺控件
拖动距离的计算与滚轮控件一样,只是记录为 mOffsetLeft 而已,同时两个控件都有在 onTouchEvent 的 ACTION_UP 事件分发中,处理 fling 手势。不过卷尺控件有使用 EdgeEffect 处理边缘效果,有兴趣的同学可以看下。
结语
文章的主要目的并不在于教会怎么去处理触摸事件的分发,只是希望通过这个例子,大家能养成阅读源码的习惯,不管是系统 SDK 也好,第三方库也好,只要把核心知识掌握,就能熟练使用各种现成的工具库,并且达到举一反三的效果。
但是理论知识再多也是纸上谈兵,最重要的是实践,具体实践可以这样做:先理解好触摸事件分发流程,然后选择一个控件,可以是系统控件,也可以是其他控件,只要涉及触摸事件处理就行,进行阅读,然后手动实现一个相反方向滚动的控件,比如,你阅读的是纵向滑动的控件,那么就实现一个横向滑动的控件。这个自定义控件需要实现以下效果:
最基本的拖动手势处理
fling 效果实现
如果可以,再实现边缘效果
感谢大家的阅读。