Android触摸事件总结

前言

        最近公司面试 Android 岗位,面试的同学大部分具有多年的工作经验,但发现很多同学对触摸事件的分发都不是很熟悉,有的可能还能照本宣科地说个七七八八,有的却完全不熟悉,这让我感觉到诧异,因为我认为这 Android 中非常基础的知识,不仅要熟悉其中的分发流程,还要能实现简单的涉及手势处理的自定义控件。所以,这也是这篇文章的目的,希望能通过一些触摸事件处理的实际例子,来加深对触摸事件分发的理解,同时能实现相关需求。



触摸事件简单回顾

    我们都知道触摸事件的分发严格意义上讲,是从 Activity 开始进行分发,但一般我们谈论的,只从 ViewGroup 开始分发。


Android触摸事件总结_第1张图片

        图只是粗略地画出触摸事件的分发流程,从 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 ,但不管怎样,只要能理解好触摸事件分发,对工具类的熟练使用就不在话下。



实战

理论说的再多,也是纸上谈兵,只有真正去实践,才能熟练掌握。因为业务需求或者兴趣爱好,我写了以下两个自定义控件,正好一个是纵向滑动和一个是横向滑动,效果如下:


Android触摸事件总结_第2张图片

                                                                                                                滚轮控件 ↑


                                                                                                        仿薄荷健康的卷尺控件↑

分析部分代码:

滚轮控件

在前面手势判定中的分析中,我们提到在 onTouchEvent 判定拖动手势成功后,进行真正的业务逻辑处理,在这个控件中也是一样的:

if(mIsBeingDragged) {

    mDistanceY += mLastY - moveY;

    postInvalidateOnAnimation();

}

在每次 TOUCH_MOVE 事件分发时,计算与 TOUCH_DOWN 时记录的位置信息的差值,保存为 mDistanceY,并且在 onDraw 中使用这个值对 Canvas 进行位移,绘制新位置的 UI。

卷尺控件

    拖动距离的计算与滚轮控件一样,只是记录为 mOffsetLeft 而已,同时两个控件都有在 onTouchEvent 的 ACTION_UP 事件分发中,处理 fling 手势。不过卷尺控件有使用 EdgeEffect 处理边缘效果,有兴趣的同学可以看下。



结语

文章的主要目的并不在于教会怎么去处理触摸事件的分发,只是希望通过这个例子,大家能养成阅读源码的习惯,不管是系统 SDK 也好,第三方库也好,只要把核心知识掌握,就能熟练使用各种现成的工具库,并且达到举一反三的效果。

但是理论知识再多也是纸上谈兵,最重要的是实践,具体实践可以这样做:先理解好触摸事件分发流程,然后选择一个控件,可以是系统控件,也可以是其他控件,只要涉及触摸事件处理就行,进行阅读,然后手动实现一个相反方向滚动的控件,比如,你阅读的是纵向滑动的控件,那么就实现一个横向滑动的控件。这个自定义控件需要实现以下效果:

最基本的拖动手势处理

fling 效果实现

如果可以,再实现边缘效果

感谢大家的阅读。

你可能感兴趣的:(Android触摸事件总结)