札记: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。

相关知识

posted @ 2016-11-17 22:05  everhad  阅读(4421)  评论(0编辑  收藏  举报