美文网首页
Android触摸事件总结

Android触摸事件总结

作者: 一汪鼻涕向东流 | 来源:发表于2018-05-02 09:41 被阅读0次

    前言

            最近公司面试 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 效果实现

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

    感谢大家的阅读。

    相关文章

      网友评论

          本文标题:Android触摸事件总结

          本文链接:https://www.haomeiwen.com/subject/sjyqrftx.html