• 札记:android手势识别,MotionEvent


    摘要

    本文是手势识别输入事件处理的完整学习记录。内容包括输入事件InputEvent响应方式,触摸事件MotionEvent的概念和使用,触摸事件的动作分类、多点触摸。根据案例和API分析了触摸手势Touch Gesture的识别处理的一般过程。介绍了相关的GestureDetector,Scroller和VelocityTracker。最后分析drag和scale等一些手势的识别。

    输入源分类

    虽然android本身是一个完整的系统,它主要运行在移动设备的特性决定了我们在它上面开的app绝大数属于客户端程序,主要目标就是显示界面处理交互,这点和web前端以及桌面上的应用类似。
    作为“客户端程序”,编写的大部分功能就是处理用户交互。不同系统(对应不同设备)可支持的用户交互各有不同。
    android可以运行在多种设备,从交互输入上看,InputDevice.SOURCE_CLASS_xxx常量标识了sdk所支持的几种不同输入源的设备。有:触屏,物理/虚拟按键,摇杆,鼠标等,下面的讨论针对最广泛的交互——触屏( SOURCE_TOUCHSCREEN)。
    触屏设备从交互设计上看就是各种手势,有点击,双击,滑动,拖拽,缩放等等交互定义,本质上它们都是基础的几种触摸事件的不同模式的组合。
    在安卓触屏系统中,支持单点、多点(点通常就是手指)触摸,每个点有按下,移动和抬起。
    触屏交互的处理分不同触屏操作——手势的识别,然后是根据业务对应不同处理。为了响应不同的手势,首先就需要识别它们。识别过程就是跟踪收集系实时提供的反应用户在屏幕上的动作的"基本事件",然后根据这些数据(事件集合)来判定出各种不同种类的高级别的“动作”。
    android.view.GestureDetector提供了对onScroll、onLongPress、onFling等几个最常见动作的监听。而自己的app根据需要可以通过实现自己的GestureDetector类型来识别出类似Drag、Scale这样的交互动作。

    手势识别是智能手机和平板等触屏设备的主流交互/输入方式,不同于PC上的键盘和鼠标。

    输入事件

    用户交互产生的输入事件最终由InputEvent的子类来表示,目前包括KeyEvent(Object used to report key and button events)和MotionEvent(Object used to report movement (mouse, pen, finger, trackball) events.)。

    接收InputEvent的地方有很多,根据框架对事件的传播路径依次有Activity、Window、View(ViewTree的一条路径:view stack)。
    多数情况下都是在用户交互的具体View中接收并处理这些输入事件。
    View的事件处理有2种方式,一种是添加监听器(event listener),另一种是重写处理器方法( event handler)。前者比较方便,后者在自定义View时根据需要去重写,而且CustomView也可以根据需要定义自己的处理器方法,或提供监听接口。

    事件监听

    事件监听接口都是只包含一个方法的interface,如:

    // 在View.java中
    public interface OnTouchListener {
      boolean onTouch(View v, MotionEvent event);
    }
    
    public interface OnLongClickListener {    
      boolean onLongClick(View v);
    }
    
    public interface OnClickListener {
      void onClick(View v);
    }
    
    public interface OnKeyListener {
      boolean onKey(View v, int keyCode, KeyEvent event);
    }
    

    在Activity等地方通过创建匿名类或实现对应接口(省去新类型和对象的分配)然后调用
    View.setOn...Listener()来完成注册监听。

    根据android的ui-events(输入事件)的传递机制,监听器的回调方法会先于各种相应的处理器方法被执行,对于那些有返回boolean值的回调方法,返回值表示是否让事件继续被传播,所以应该根据需要谨慎设计返回值,否则会阻塞其它处理的执行。

    例如,当为View设置OnTouchListener之后,若回调方法onTouch返回true,那么在View的boolean dispatchTouchEvent(MotionEvent event)中执行了回调方法后,就不再执行View中的处理器方法boolean onTouchEvent(MotionEvent event)

    事件处理器

    事件处理器就是在“事件传递”经过当前View时调用的默认方法。通常也就是对应具体View的行为逻辑的实现(要知道监听器不是必须的,甚至可以不去定义,而任何View都会为感兴趣的事件提供处理)。

    有关消息传递的知识可以写一整篇了,这里略过,只需要知道,输入事件会沿着ViewTree自顶向下穿过许多“相关的”View,然后这些View处理或继续传递事件。事件到达ViewTree之前还会经过Activity和Window,最终的起源当然是系统负责收集的硬件事件,从“事件管理器”发送给交互中的界面相关的某个类,开始传播。

    View类中包括下面的事件处理方法:

    • onKeyDown(int, KeyEvent) - Called when a new key event occurs.
    • onKeyUp(int, KeyEvent) - Called when a key up event occurs.
    • onTrackballEvent(MotionEvent) - Called when a trackball motion event occurs.
    • onTouchEvent(MotionEvent) - Called when a touch screen motion event occurs.
    • onFocusChanged(boolean, int, Rect) - Called when the view gains or loses focus.

    上面的处理器方法是站在事件传播管道的当前节点来进行处理的,也就是处理只需要考虑当前View所提供的功能逻辑,并告知调用者是否已经处理结束——需要继续传递?而对于ViewGroup类,它还承担传递事件给childView的任务,下面的方法和事件传递密切相关:

    • Activity.dispatchTouchEvent(MotionEvent) - This allows your Activity to intercept all touch events before they are dispatched to the window.
    • ViewGroup.onInterceptTouchEvent(MotionEvent) - This allows a ViewGroup to watch events as they are dispatched to child Views.
    • ViewParent.requestDisallowInterceptTouchEvent(boolean) - Call this upon a parent View to indicate that it should not intercept touch events with onInterceptTouchEvent(MotionEvent).

    了解在哪些地方可以接收事件,什么时候去处理消耗事件是界面编程的一个重要方面,但“输入事件的传递过程”是一个重要且够复杂的话题,本篇文章重点是触屏事件的各种手势识别,相关的知识仅从“理解的完整和条理性”出发占据一定篇幅。

    TouchMode

    对于触屏设备,用户开始触摸直到离开屏幕(press->lift)期间,界面会处于TouchMode的交互状态。大致来看,所有的View都在响应触摸事件或者其它的KeyEvent(按键,按钮等)事件。两者在交互上截然不同,触摸模式的状态维护贯穿了整个系统,包括所有的Window和Activity对象(主要就是触摸事件的分发的控制),通过View类的public boolean isInTouchMode ()方法可以查看当前设备是否处在触摸模式。

    Gestures

    用户手指(一或多个)按下和最终完全离开屏幕的过程为一次触屏操作,每次操作都可归类为不同触摸模式(touch pattern),最终被定义为不同的手势(手势和模式的定义是设计上的,用户在使用任何触屏设备后都会学习到不同的手势),android支持的主要手势有:

    • Touch
    • Long press
    • Swipe or drag
    • Long press drag
    • Double touch
    • Double touch drag
    • Pinch open
    • Pinch close

    app需要根据系统提供的API来响应这些手势。

    手势识别过程

    为了实现对手势的响应处理,需要理解触摸事件的表示。而识别手势的具体过程包括:

    1. 获得触摸事件数据。
    2. 分析是否匹配所支持的某个手势。

    MotionEvent

    触摸动作触发的输入事件由MotionEvent表示,它实现了Parcelable接口——IPC需求。
    目前的设备几乎都支持多点触摸,每个触摸中的手指被当做一个poiner。MotionEvent记录了目前所有处于触摸的poiner,包含它们各自的X,Y坐标,压力,接触区域等信息。

    每个手指的按下、移动和抬起都会产生一个事件对象。每个事件对应一个“动作”,由MotionEvent.ACTION_xxx的常量来表示:

    • 在第一个手指按下时,触发ACTION_DOWN
    • 后续手指按下时触发ACTION_POINTER_DOWN
    • 任何一个手指的移动触发ACTION_MOVE
    • 非最后一个手指抬起触发ACTION_POINTER_UP
    • 最后离开屏幕时触发ACTION_UP
    • 触摸事件序列被中断时触发ACTION_CANCEL,一般是对应View的parent阻止的,比如触摸超出区域时。

    每一个手指的down,move和up都会产生事件。出于性能考虑,因为移动过程会产生大量的ACTION_MOVE事件,它们被“批量”发送,也就是一个MotionEvent中将可以包含若干个实际的ACTION_MOVE事件数据,很显然,这些事件都是MOVE动作,而且poiner数量是一样的——任何poiner的加入和去除都引发DOWN、UP事件,这样就不是连续的MOVE事件了。

    相比上一个MotionEvent数据,当前MotionEvent的所有数据都是最新的。打包的数据根据时间形成数组,而最新的数据被作为current数据。可以通过getHistorical系列方法访问“历史事件”的数据。

    下面是获得当前MotionEvent中所有事件的各个poiner的坐标的标准形式:

    void printSamples(MotionEvent ev) {
       final int historySize = ev.getHistorySize();
       final int pointerCount = ev.getPointerCount();
       for (int h = 0; h < historySize; h++) {
           System.out.printf("At time %d:", ev.getHistoricalEventTime(h));
           for (int p = 0; p < pointerCount; p++) {
               System.out.printf("  pointer %d: (%f,%f)",
                   ev.getPointerId(p), ev.getHistoricalX(p, h), ev.getHistoricalY(p, h));
           }
       }
       System.out.printf("At time %d:", ev.getEventTime());
       for (int p = 0; p < pointerCount; p++) {
           System.out.printf("  pointer %d: (%f,%f)",
               ev.getPointerId(p), ev.getX(p), ev.getY(p));
       }
    }
    

    前面提到了,事件具有动作分类,而且每个事件对象中包含所有pointer的相关数据。获得action的方式是:

    action = event.getAction() & MotionEvent.ACTION_MASK;
    
    • getAction和getActionMasked
      getAction()返回的int数值内可能包含pointerIndex的信息(这里应该是类似View.MeasureSpec那样利用bit位来提升性能的做法):对应ACTION_POINTER_DOWN和ACTION_POINTER_UP动作,返回值包含了触发UP、DOWN的“当前”pointer的index值,然后可以在方法 getPointerId(int), getX(int), getY(int), getPressure(int), and getSize(int)中作为pointerIndex参数使用。方法getActionIndex()就是用来获取其中的pointerIndex。而getActionMasked()和上面语句的执行逻辑是一样的——返回不包含pointerIndex的action常量值。对应只有一个手指的情况,显然getAction()和getActionMasked()是一样的,因为返回值本身也没有额外的pointerIndex数据。获得事件动作应该使用getActionMasked——更准确些。

    获得某个pointer的数据的方式也比较特殊,比如获得各个pointer的X坐标:

    final int pointerCount = ev.getPointerCount();
    // p就是pointerIndex
    for (int p = 0; p < pointerCount; p++) {
       System.out.printf("  pointer %d: (%f,%f)",
           ev.getPointerId(p), ev.getX(p), ev.getY(p));
    }
    

    在一次手势操作过程中,pointer的数量可能发生变化,每一个pointer在DOWN事件的时候就获得一个关联的id,可以作为它的有效标识,直至UP或CANCEL后(pointerCount变化)。
    在单个的MotionEvent对象中,getPointerCount()返回了处于触摸的pointer的总数,0~getPointerCount()-1的值就是当前所有pointer的pointerIndex。方法float getX(int pointerIndex)接收index来获得对应pointer的X坐标值。
    类似的,其它接收pointerIndex参数的方法用以获得pointer的其它属性。如果需要关注某个手指的连续动作,比如第一个按下的手指,可以通过方法int getPointerId(int pointerIndex)获得pointerIndex的id,记录此id,然后在每个MotionEvent数据检查时通过方法int findPointerIndex(int pointerId)得到id在当前MotionEvent数据中对应的pointerIndex,就可以访问连续事件中指定id的pointer的属性了。

    最后,MotionEvent的以下方法是经常用到的:

    • long getEventTime() 获得事件发生的时间。
    • long getDownTime() 获得本次触摸事件序列的第一个——手指按下(ACTION_DOWN)的发生时间。
    • int getAction()int getActionMasked()int getActionIndex()int getPointerCount()int getPointerId(int pointerIndex)float getX()float getX(int pointerIndex)等。

    接收事件数据

    手势操作产生的一系列MotionEvent对象依次分发出去,传递并经过一些UI相关对象,一般的最终会经过对应的Activity和组成界面的那些和当前触屏相关的View对象——沿着ViewTree从事件所在View向上的各个parent。

    在当前界面的Activity中,可以通过重写Activity的boolean onTouchEvent(MotionEvent event)方法来接收触摸事件,更多时候,因为View是具体实现UI交互的地方,所以在View的boolean onTouchEvent(MotionEvent event)方法中接收事件。
    一次触摸操作会发送一系列事件,所以onTouchEvent会被“很多次”调用。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction() & MotionEvent.ACTION_MASK;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                Log.d(TAG, "ACTION_DOWN");
                return true;
            case MotionEvent.ACTION_POINTER_DOWN:
                Log.d(TAG, "ACTION_POINTER_DOWN");
                return true;
            case MotionEvent.ACTION_MOVE:
                Log.d(TAG, "ACTION_MOVE");
                return true;
            case MotionEvent.ACTION_UP:
                Log.d(TAG, "ACTION_UP");
                return true;
            case MotionEvent.ACTION_POINTER_UP:
                Log.d(TAG, "ACTION_POINTER_UP");
                return true;
            case MotionEvent.ACTION_CANCEL:
                Log.d(TAG, "ACTION_CANCEL");
                return true;
            default:
                Log.d(TAG, "default: action = " + action);
                return super.onTouchEvent(event);
        }
    }
    

    也可以通过设置监听器来接收触摸事件,这是针对具体的View对象进行的:

    myView.setOnTouchListener(new OnTouchListener() {
        public boolean onTouch(View v, MotionEvent event) {
            // ... Respond to touch events       
            return true;
        }
    });
    

    需要注意的是,不论识别那种手势操作,ACTION_DOWN动作一定需要返回true,否则按照调用约定,将认为当前处理忽略本次触摸操作的事件序列,后续事件不会收到。

    检测手势

    在重写的onTouch回调方法中根据收到的事件序列就可以判定出各种手势。例如,一个ACTION_DOWN,紧接着是一系列的ACTION_MOVE,然后是ACTION_UP,这样的序列通常就是scroll/drag手势。总的说来,在实现识别手势的逻辑时,需要“精心设计”代码,往往需要考虑多少偏移才被当做有效滑动,多少时间间隙的down、up才算tap。android.view.GestureDetector提供了对最常见的手势的识别。下面分别对手势识别的关键相关类型做介绍。

    GestureDetector

    它的作用就是识别onScroll、onFling onDown(), onLongPress()等操作。将收到的MotionEvent序列传递给GestureDetector,之后它触发对应不同手势的回调方法。
    使用过程为:

    1. 准备GestureDetector对象,提供响应各种手势回调方法的监听器。OnGestureListener就是对不同手势的回调接口,很好理解。
    // public GestureDetector(Context context, OnGestureListener listener);
    mDetector = new GestureDetector(this, mGestureListener);
    
    1. 在onTouch方法中将收到的事件传递给GestureDetector。
    @Override
    public boolean onTouchEvent(MotionEvent event) {
       boolean handled = mDetector.onTouchEvent(event);
       return handled || super.onTouchEvent(event);
    }
    

    如果只对GestureDetector的个别手势的回调感兴趣,监听器可以继承GestureDetector.SimpleOnGestureListener。在onDown方法中需要返回true,否则后续事件会被忽略。

    手势运动

    手势可以分为运动型和非运动型。比如tap(轻敲)就没有移动,而scroll要求手指有一定的移动距离。手指是否发生运动的判定有一个临界值:touch slop,可以通过android.view.ViewConfiguration#getScaledTouchSlop获得,表示触摸被判定为滑动的最小距离。
    非运动型手势,比如点击类型的,识别的逻辑主要是对“时间间隙”的检测。运动型手势稍复杂些,对运动的判定根据实际功能需要可以获得有关运动的不同方面:

    • pointer的start和end位置。
    • 根据触摸的x,y坐标计算出的移动方向。
    • 通过 getHistorical 可以获得每次MOVE动作时运动轨迹的所有详细位置。
    • pointer移动时的速度。

    VelocityTracker

    有时对手势运动过程中的速度感兴趣,可以通过android.view.VelocityTracker来根据收集的事件数据计算得到运动时的速度:

    public class MainActivity extends Activity {
      private static final String DEBUG_TAG = "Velocity";
          ...
      private VelocityTracker mVelocityTracker = null;
      @Override
      public boolean onTouchEvent(MotionEvent event) {
          int index = event.getActionIndex();
          int action = event.getActionMasked();
          int pointerId = event.getPointerId(index);
    
          switch(action) {
              case MotionEvent.ACTION_DOWN:
                  if(mVelocityTracker == null) {
                      // Retrieve a new VelocityTracker object to watch the velocity of a motion.
                      mVelocityTracker = VelocityTracker.obtain();
                  }
                  else {
                      // Reset the velocity tracker back to its initial state.
                      mVelocityTracker.clear();
                  }
                  // Add a user's movement to the tracker.
                  mVelocityTracker.addMovement(event);
                  break;
              case MotionEvent.ACTION_MOVE:
                  mVelocityTracker.addMovement(event);
                  // When you want to determine the velocity, call
                  // computeCurrentVelocity(). Then call getXVelocity()
                  // and getYVelocity() to retrieve the velocity for each pointer ID.
                  mVelocityTracker.computeCurrentVelocity(1000);
                  // Log velocity of pixels per second
                  // Best practice to use VelocityTrackerCompat where possible.
                  Log.d("", "X velocity: " +
                          VelocityTrackerCompat.getXVelocity(mVelocityTracker,
                          pointerId));
                  Log.d("", "Y velocity: " +
                          VelocityTrackerCompat.getYVelocity(mVelocityTracker,
                          pointerId));
                  break;
              case MotionEvent.ACTION_UP:
              case MotionEvent.ACTION_CANCEL:
                  // Return a VelocityTracker object back to be re-used by others.
                  mVelocityTracker.recycle();
                  break;
          }
          return true;
      }
    }
    

    Scroller

    不严谨的区分下,scroll可以分跟随手指的滑动——drag,和手指划过屏幕后的附加减速滑动——fling。
    通常,需要对手势运动进行响应,比如画面跟随手指的移动而移动(平移),简单的实现就是在ACTION_MOVE中即时偏移对应的x,y,这种情况下对动作的“响应时机”是显而易见的。另一些情况下,需要达到平滑的滑动效果,但每次执行滑动的时机和滑动的增量都需要计算。比如,点击上一页,下一页按钮后执行的滚动翻页效果——类似ViewPager的动画效果那样。再一种情况是,手指快速划过屏幕后,需要让显示的内容继续滑动然后渐渐停止——fling效果。这些情况下,都需要在未来一段时间内,不断调整画面,达到滚动动画效果——每次执行滑动的时机和偏移量都需要计算。可以借助Scroller来完成“smoothly move”这样的动画效果。

    推荐使用android.widget.OverScroller,它兼容性好,且支持边缘效果。和VelocityTracker一样,Scroller是一个“计算工具”,它支持startScroll、fling两个滑动效果,和上面的例子对应。从设计上,它独立于滚动效果的执行,只提供对滚动动画过程的计算和状态判定。

    Scroller的使用流程:

    1. 准备Scroller对象。
    // 在构造函数,onCreate等合适的初始化的地方
    mScroller = new OverScroller(context);
    
    1. 在合适的时候开启滚动动画。一般的,fling效果会结合GestureDetector,识别出手指的fling手势后开启滚动动画:在OnGestureListener中的onFling中执行Scroller.fling()方法。
      Scroller.fling()所开启的“平滑的滑动效果”可以在任何需要开启滑动的时候执行。
    mScroller.fling(startX, startY, velocityX, velocityY,
                minX, maxX, minY, maxY, overX, overY);
    
    mScroller.startScroll(startX, startY, dx, dy, duration);
    
    1. 在动画的每一帧的执行时刻,计算滚动增量,应用到具体View对象。在自定义View时,可以依靠android.view.View#postOnAnimationandroid.view.View#postInvalidateOnAnimation()方法简单的触发在下一动画帧,以执行动画操作。或者使用Animation等可以获得动画帧执行频率的机制。View本身有computeScroll()方法可以供子类执行动画式滚动逻辑——结合postInvalidateOnAnimation()
    boolean animEnd = false;
    if (mScroller.computeScrollOffset()) {
      int currX = mScroller.getCurrX();
      int currY = mScroller.getCurrY();
      // 修改Viewx,y位置,可以使用View的scroll方法
    } else {
      animEnd = false;
    }
    
    if (!animEnd) {
      postInvalidateOnAnimation();
    }
    

    ScrollViewHorizontalScrollView自身提供了滚动功能,ViewPager也使用Scroller完成平滑的滑动行为。一般在自定义带滑动行为的控件时使用Scroller。框架的几个控件使用EdgeEffect完成一些边缘效果。

    Multi-Touch

    上面对MotionEvent的介绍中可以看到,每个处于触摸的手指被当做一个pointer。目前大多数手机设备几乎都是支持10点触摸。
    是否考虑多点触摸是根据View的功能而定。比如scroll一般一个手指就可以,而scale这一的就必须2个手指以上了。

    MotionEvent的getPointerId和findPointerIndex方法提供了对当前事件数据的每个pointer的标识,根据pointerIndex可以调用其它以它为参数的方法获得对应pointer的不同方面的值。pointerId可以作为一个pointer触屏期间的唯一标识。

    private int mActivePointerId;
    
    public boolean onTouchEvent(MotionEvent event) {
        ....
        // Get the pointer ID
        mActivePointerId = event.getPointerId(0);
    
        // ... Many touch events later...
    
        // Use the pointer ID to find the index of the active pointer
        // and fetch its position
        int pointerIndex = event.findPointerIndex(mActivePointerId);
        // Get the pointer's current position
        float x = event.getX(pointerIndex);
        float y = event.getY(pointerIndex);
    }
    

    对于单点触摸,通常在onTouchEvent方法中根据getAction就可以判定出对应动作。而多点触摸时需要使用getActionMasked方法。区别前面提到了,下面的代码片段给出了有关多点触摸的一般API:

    int action = MotionEventCompat.getActionMasked(event);
    // Get the index of the pointer associated with the action.
    int index = MotionEventCompat.getActionIndex(event);
    int xPos = -1;
    int yPos = -1;
    
    Log.d(DEBUG_TAG,"The action is " + actionToString(action));
    
    if (event.getPointerCount() > 1) {
        Log.d(DEBUG_TAG,"Multitouch event");
        // The coordinates of the current screen contact, relative to
        // the responding View or Activity.  
        xPos = (int)MotionEventCompat.getX(event, index);
        yPos = (int)MotionEventCompat.getY(event, index);
    
    } else {
        // Single touch event
        Log.d(DEBUG_TAG,"Single touch event");
        xPos = (int)MotionEventCompat.getX(event, index);
        yPos = (int)MotionEventCompat.getY(event, index);
    }
    ...
    
    // Given an action int, returns a string description
    public static String actionToString(int action) {
        switch (action) {
            case MotionEvent.ACTION_DOWN: return "Down";
            case MotionEvent.ACTION_MOVE: return "Move";
            case MotionEvent.ACTION_POINTER_DOWN: return "Pointer Down";
            case MotionEvent.ACTION_UP: return "Up";
            case MotionEvent.ACTION_POINTER_UP: return "Pointer Up";
            case MotionEvent.ACTION_OUTSIDE: return "Outside";
            case MotionEvent.ACTION_CANCEL: return "Cancel";
        }
        return "";
    }
    

    类MotionEventCompat提供了一些多点触摸相关辅助方法,兼容版本。

    ViewConfiguration

    该类提供了一些UI相关的常量,关于超时时间,大小,和距离等。会根据系统的版本和运行的设备环境,如分辨率,尺寸等,提供统一的标准参考值,为UI元素提供一致的交互体验。

    • Touch Slop
      表示pointer被视为滚动手势的最小的移动距离。

    • Fling Velocity
      表示手指移动被视为触发fling的临界速度。

    ViewGroup管理TouchEvent

    事件拦截

    在非ViewGroup的View中响应触摸事件的“职责”比较单一,就是根据当前View的交互需求识别然后执行交互逻辑。也就是只需要在android.view.View#onTouchEvent中处理触摸产生的事件序列。

    ViewGroup继承View,所以它本身可以很据需要在onTouchEvent()中处理事件。另一方面,作为其它View的parent,它必须对childViews执行layout,并且有控制MotionEvent传递给目标childView的方法onInterceptTouchEvent()。注意ViewGroup本身可以处理事件,因为它同时也是合格的View子类。根据类的功能而不同,比如ViewPager会处理左右滑动的事件,但将上下滑动的事件传递给childView。要知到,ViewGroup可以包含View,也可以不包含。所以实际的事件有的是childView应该处理的,有的是“落在”ViewGroup本身区域内。

    相关方法

    有关事件分发的机制这里只简单提及,ViewGroup可以管理MotionEvent的传递。涉及到下面的方法:

    boolean onInterceptTouchEvent(MotionEvent ev)

    该方法用来拦截传递给目标childView(可以是ViewGroup,这里不一定是事件的最终目标view,而是事件传递路径经过当前ViewGroup后的下一个view)的MotionEvent事件,可以做些额外操作,甚至是阻止事件的传递自己处理。如果ViewGroup希望自己的onTouchEvent()处理手势事件,可以重写此方法并在onTouchEvent()中配合完成期望的手势处理。

    • 事件经过ViewGroup的顺序
    1. onInterceptTouchEvent()中接收到down事件,作为后续事件的起点。
    2. down事件可以被childView处理,或者由当前ViewGroup的onTouchEvent()方法处理。自己处理时onInterceptTouchEvent()返回true,对应onTouchEvent()也应该返回true,这样ViewGroup就可以收到后续的事件,否则——onInterceptTouchEvent()返回true,而onTouchEvent()返回false——后续事件将交给ViewGroup的parent处理。在两个方法都返回true之后,后续事件就直接交给ViewGroup的onTouchEvent()去处理,onInterceptTouchEvent()不再收到后续事件。
    3. 该方法在donw事件返回false,后续所有事件,先传递到该方法,然后是给对应目标childView:的onTouchEvent()或onInterceptTouchEvent()方法——和当前ViewGroup同样的事件消耗规则。
    4. 方法返回true后,目标view收到同样的事件作为最后的事件,动作变为CANCEL,后续事件由ViewGroup的onTouchEvent()处理,该方法也不再收到。
    • 返回值
      Return true to steal motion events from the children and have them dispatched to this ViewGroup through onTouchEvent(). The current target will receive an ACTION_CANCEL event, and no further messages will be delivered here.

    注意:ViewGroup中onInterceptTouchEvent()和onTouchEvent()的合作对事件传递的影响主要体现在down事件的处理上,后续事件的传递受此影响。

    boolean onTouchEvent(MotionEvent event)

    ViewGroup继承View的onTouchEvent(),没有任何改变。
    其返回值含义如下:
    true表示事件被处理(消耗),这样以后事件的传递终止。
    false表示未处理,那么会沿着事件传递的路径依次返回parent中去处理——parent的onTouchEvent()被执行,直到某个parent的onTouchEvent()返回true。

    void requestDisallowInterceptTouchEvent(boolean disallowIntercept)

    该方法上由childView调用的。childView调用后并传递true时,会沿着ViewTree中root到目标View的view hierarchy一直向上依次通知各个parent去设置一个和触摸相关的标记FLAG_DISALLOW_INTERCEPT,传递false或者一次触摸操作结束后会清除此标记。
    档ViewGroup包含此标记时,其默认的行为是在通过方法boolean dispatchTouchEvent(MotionEvent ev)分发事件的时候会忽略调用onInterceptTouchEvent()去拦截事件。

    拓展:Dragging和Scaling

    Drag操作

    android 3.0以上提供了api对拖拽进行支持,见 View.OnDragListener。下面自己处理onTouchEvent()方法来响应drag操作,移动目标View。

    实现的重点是对移动距离的检测,按照设计,从第一个手指触摸目标View引发down操作开始,只要还有手指处于触摸状态,就检测对应手指的移动来移动View。移动的距离是计算pointer的MOVE动作对应事件x,y坐标的距离。需要注意的是,必须是检测同一个pointer,因为允许多点触摸,那么就需要记录一个作为移动参考的pointer——定义为activePointer。规则是:第一个手指ACTION_DOWN时记录对应pointerId作为activePointer,如果有手指离开就记录剩余的某个pointer作为新的activePointer。
    在ACTION_MOVE中获得新的x,y和最后的(每次设置activePointer时记录对应x,y作为最后的坐标)坐标进行对比,计算产生的距离就是移动距离。

    // The ‘active pointer’ is the one currently moving our object.
    private int mActivePointerId = INVALID_POINTER_ID;
    
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // Let the ScaleGestureDetector inspect all events.
        mScaleDetector.onTouchEvent(ev);
    
        final int action = MotionEventCompat.getActionMasked(ev);
    
        switch (action) {
        case MotionEvent.ACTION_DOWN: {
            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);
    
            // Remember where we started (for dragging)
            mLastTouchX = x;
            mLastTouchY = y;
            // Save the ID of this pointer (for dragging)
            mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
            break;
        }
    
        case MotionEvent.ACTION_MOVE: {
            // Find the index of the active pointer and fetch its position
            final int pointerIndex =
                    MotionEventCompat.findPointerIndex(ev, mActivePointerId);  
    
            final float x = MotionEventCompat.getX(ev, pointerIndex);
            final float y = MotionEventCompat.getY(ev, pointerIndex);
    
            // Calculate the distance moved
            final float dx = x - mLastTouchX;
            final float dy = y - mLastTouchY;
    
            mPosX += dx;
            mPosY += dy;
    
            invalidate();
    
            // Remember this touch position for the next move event
            mLastTouchX = x;
            mLastTouchY = y;
    
            break;
        }
    
        case MotionEvent.ACTION_UP: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }
    
        case MotionEvent.ACTION_CANCEL: {
            mActivePointerId = INVALID_POINTER_ID;
            break;
        }
    
        case MotionEvent.ACTION_POINTER_UP: {
    
            final int pointerIndex = MotionEventCompat.getActionIndex(ev);
            final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);
    
            if (pointerId == mActivePointerId) {
                // This was our active pointer going up. Choose a new
                // active pointer and adjust accordingly.
                final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex);
                mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex);
                mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
            }
            break;
        }
        }       
        return true;
    }
    

    上面的方法分别在ACTION_DOWN和ACTION_POINTER_UP中设置mActivePointerId,以及上一次的触摸位置。在ACTION_MOVE中记录移动到的位置,以及更新最后的触摸位置。最后,在UP、CANCEL中清除记录的pointerId。

    可见,drag手势的识别重点就是记录作为移动参考的pointerId,它必须是连续的。
    对于drag操作的识别和响应,可以直接使用GestureDetector响应其中的onScroll()方法即可。
    scroll,drag和pan这些都是一样的手势/操作。

    Scale

    可以使用ScaleGestureDetector来检测缩放动作。下面的例子是drag和scale一起识别的代码范例,注意其识别操作对事件的消耗顺序:

    private ScaleGestureDetector mScaleDetector;
    private GestureDetector mGestureDetector;
    private float mScaleFactor = 1.f;
    
    public MyCustomView(Context mContext){
        ...
        mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
    }
    
    ...
    public boolean onTouchEvent(MotionEvent event) {
        boolean retVal = mScaleGestureDetector.onTouchEvent(event);
        retVal = mGestureDetector.onTouchEvent(event) || retVal;
        return retVal || super.onTouchEvent(event);
    }
    
    private class ScaleListener
            extends ScaleGestureDetector.SimpleOnScaleGestureListener {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            mScaleFactor *= detector.getScaleFactor();
    
            // 控制缩放的最大值
            mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));
    
            // 缩放系数变化后通知View重绘
            invalidate();
            return true;
        }
    }
    

    关于GestureDetector的用法前面给出了,上面代码片段只展示ScaleGestureDetector、ScaleGestureDetector.SimpleOnScaleGestureListener的一般用法。
    注意onTouchEvent()中先执行ScaleGestureDetector的事件检测,然后是GestureDetector的,只要两次识别都未处理时,才调用父类的默认行为。

    小结

    理解手势识别的整体过程是在onTouchEvent中根据MotionEvent事件序列来匹配不同的模式是整片文章的目标。要知道,GestureDetector和ScaleGestureDetector这些框架提供的类型都是方便大家在自定义View时的手势识别功能的实现。只要掌握手势识别的思路,可以自己识别任何期望的触摸事件模式。不过,研究框架GestureDetector的源码,以及一些开源的控件中对手势操作的处理是一个很好的开始。

    资料

    官方文档

    文章主要内容参考来自api 22的开发文档。

    • Using Touch Gestures
      文件路径:/docs/training/gestures/detector.html
    • Input Events
      文件路径:/docs/guide/topics/ui/ui-events.htm

    案例:PhotoView

    在自定义View时根据需要会出现监听特殊的手势的需要,这个时候就需要定义自己的GestureDetector类型了。研究系统的GestureDetector类的实现非常有帮助,如果需要识别多种手势时,根据实际的特征,可以设计多个Detector类型,用来识别不同手势,但需要注意在使用它们时对事件的消耗顺序,比如drag和scale手势的先后识别。
    开源项目PhotoView用来展示图片并支持各种手势对图片进行缩放,平移等操作。它里面包含了几个手势识别的类,建议可以阅读它的代码来作为对手势识别的“实现细节”的实践。
    项目地址:https://github.com/chrisbanes/PhotoView。

    相关知识

  • 相关阅读:
    SQLServer 高可用、高性能和高保护延伸
    SQLServer 通过DMV实现低影响的自动监控和历史场景追溯
    查找表包含的页和页所在的表
    出身在二三线城市软件工作者的悲哀
    SQL语句实现取消自增列属性
    基于Apache(without ssl)的svn环境搭建
    sqlite3 命令行操作
    HTML常用特殊符号集
    IOS项目目录结构和开发流程
    Mac OSX 快捷键&命令行
  • 原文地址:https://www.cnblogs.com/everhad/p/6075716.html
Copyright © 2020-2023  润新知