稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Android事件高级手势处理

GestureDetector只能帮我们处理并识别一些常用的简单的手势,如点击,双击,长按,滑动(Scroll)和快速滑动(Fling)等,一般情况下,这些足够我们使用了,但有些时候需要一些更为复杂的手势操作,如Translate,Zoom,Scale和Rotate,以及像处理一些多点触控(MultiTouch),这就需要开发人猿自己处理了,本文将讨论一下这些内容。

高级手势识别

移动(Translate/Drag)

这里的移动的意思是让物体随着手指在屏幕上移动,或者叫作拖拽。而且这个只需要一个手指就可以办到,不涉及多点触控。

其实,这个实现起来并不复杂,从onTouchEvent处获得事件后,不断的用MotionEvent的坐标来刷新目标View即可,甚至都不用管具体的事件类型,因为无论是ACTION_DOWN,ACTION_UP或者ACTION_MOVE,都可以提供新的坐标,只管从事件处取坐标然后刷新就可以了。

1
2
3
4
5
6
7
   draw at (x0, y0);

   onTouchEvent(event) {
      x = event.getRawX();
      y = event.getRawY();
      invalidate with (x, y); // will draw at (x, y);
   }

旋转(Rotate)

同样,对于旋转用单个手指也可以办到,以目标View当前的位置为圆心,以手指划过的曲线作为圆弧,由此便可让目标View旋转起来,而且这个手势由单个手指也可以实现,不用管多点触控。

其实可以进一步的做简化,认定屏幕中央为圆心,来计算手势划过的角度,并且为了连惯性,要以事件ACTION_MOVE过程中的增量角度来对View进行旋转,这样会让旋转看起来更顺滑一些,额外的工作是要把事件的坐标进行一下转化,转化为以屏幕中心为原点的坐标。

具体的流程是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
   lastTheta = -1;

   onTouchEvent(event) {
   switch (action) {
   case ACTION_DOWN:
      lastX = normalize(event.getX());
      lastY = normalize(event.getY());
      lastTheta = angle(lastX, lastY);
      break;
   case ACTION_MOVE:
     newX = normalize(event.getX());
     newY = normalize(event.getY());
     theta = angle(newX, newY);
     deltaTheta = alpha - beta;
     invalidate to rotate with deltaTheta;
     lastTheta = theta;
     break;
   case ACTION_CANCEL:
   case ACTION_UP:
      we are done.
   }

   normalizeX(x) {
      return 2 * x / screenWidth;
   }

   normalizeY(y) {
     return 2 * y / screenHeight;
   }

   angle(x, y) {
      return atan(y / x);
   }

至于缩放,单个手指无法完成,必须要用两个手指才可以,就涉及到多点触控,所以需要先介绍一下多点触控。

多点触控(MultiTouch)

这个并不复杂,虽然听起来像个神秘高科技,但其实,处理流程并不复杂,主体流程仍然是在onTouchEvent方法中,并且主要的对象仍是MotionEvent,文档里面基本上都说清楚了,要点就是:

  1. MotionEvent对象,会用pointerId和pointerIndex来区分不同的触控点(术语是Pointer)
  2. 事件流是:ACTION_DOWN 称为主触控点(Primary Pointer),然后是ACTION_POINTER_DOWN 另外一个触控点来了(非Primary Pointer),然后是ACTION_MOVE 这里没有显示 区分不同的pointer,需要开发人猿自己去区分,然后是ACTION_POINTER_UP 非主触控点 离开了,最后是ACTION_UP 主触控点离开了。需要注意的是,这是处理事件的逻辑上的顺序 ,真实的事件流,不一定是这样的(ACTION_DOWN肯定是第一个,ACTION_UP肯定 肯定最后一个,但中间的几个有顺序 不定)。
  3. 注意的要点,每次事件来了后,不同的触控点(Pointer)的index并不是固定的,比如上一次MOVE时它在index 0,但下次可能就在index 1,而其Pointer Id是固定的。所以在处理的整个流程中要记录不同Pointer的id,然后获得其index,再用index去取坐标啊之类的数据。
  4. 多点触控,天生就支持,所以即使你不识别多点触控手势(如scale),只关心单个手指手势,在处理的时候,仍要考虑到多点的逻辑。比如说translate时,如果不考虑多点,那么当另外一个手指触摸了屏幕,产生了ACTION_MOVE事件,但它的坐标跟最初产生事件的Pointer差距很远,那么如果不做排除,就可能产生瞬间漂移。

加强版的单触控点手势

对于前面提到的单触控点手势(单手指就能识别的手势)如Translate和Rotate,其实都需要加强一下逻辑,以防止多触控点产生的干扰。

加强版本的单触控点手势处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   primaryPointerId = INVALIDE_POINTER_ID;

   onTouchEvent(event) {
      switch (event.getActionMasked()) {
         case ACTION_DOWN:
              primaryPointer = event.getPointerId(event.getActionIndex());
              break;
         case ACTION_MOVE:
              pointerIndex = event.findPointerIndex(primaryPointerId);
              x = event.getX(pointerIndex);
              y = event.getY(pointerIndex);
              be happy with x and y;
              break;
          case ACTION_UP:
          case ACTION_CANCEL:
            primaryIndex = INVALIDE_POINTER_ID;
            break;
      }
   }

当然,这里也取决于具体的使用场景,假如允许切换触控点,比如先一个手指拖动,然后另外一个手指点进来,这时第一个手指离开了,如果想继续 拖动的话,就需要更换已保存的primaryPointer。这时会收到ACTION_POINTER_UP,需要在此做切换处理,继续 上面的代码片段,

1
2
3
4
5
6
7
8
9
10
11
  secondPointer = INVALIDE_POINTER_ID;
  case ACTION_POINTER_DOWN:
     secondPointer = event.getPointerId(event.getActionIndex());
     break;
  case ACTION_POINTER_UP:
     thisPointer = event.getPointerId(event.getActionIndex());
     if (thisPointer == primaryPointer) {
          primaryPointer = secondPointer;
     }
     secondPointer = INVALIDE_POINTER_ID;
     break;

还有一点需要注意的是,不能简单的只用getPointerCount来作判断,就比如pointer 1先来,然后pointer 2来了,pointer 1又离开了,这时pointerCount仍是1,但是pointer已变化 了,事件的位置就变了,如果不按上述方法处理,将会发生跳变。

缩放(Zoom/Scale)

缩放手势是多点触控的一个非常典型的应用,因为单手无法做出比较合理的手势判断。SDK当中提供了一个用于识别缩放的手势识别器ScaleGestureDetector,它的使用方法与GestureDetector一样,创建对象,塞MotionEvent进去,然后注册listener即可。

但如果,用单独的detector不是很方便,比如已经自己实现了一套手势识别逻辑,现在只想加上Scale,或者其他原因不方便引入ScaleGestureDetector,那么就得自己去做了,也并不是很复杂。

主要思路就是,收集齐两个触控点,记录它们初始的位置,计算它们之间初始的距离,在ACTION_MOVE时,再计算新的距离,新旧距离之比既可当作缩放的比例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
   primaryPointer = INVALIDE_POINTER_ID;
   secondPointer = INVALIDE_POINTER_ID;
   initialSpan = -1;
   startPoint = null;
   onTouchEvent(event) {
         case ACTION_DOWN:
              index = event.getActionIndex();
              primaryPointer = event.getPointerId(index);
              startPoint = Point(event.getX(index), event.getY(index));
              break;
         case ACTION_POINTER_DOWN:
              index = event.getActionIndex();
              secondPointer = event.getPointerId(index);
              sp = Point(event.getX(index), event.getY(index));
              initialSpan = distance(startPoint, sp);
             break;
         case ACTION_MOVE:
              if (event.getPointerCount() > 1) {
                  primaryIndex = event.findPointerIndex(primaryPointer);
                  pp = Point(event.getX(primaryIndex), event.getY(primaryIndex));
                  secondIndex = event.findPointerIndex(secondPointer);
                  sp = Point(event.getX(secondIndex), event.getY(secondIndex));
                  thisDistance = distance(pp, sp);
                  if (thisDistance > ScaledSpan) {
                      scale = thisDistance / initialSpan;
                      be happy with scale;
                  }
              }
              break;
         case ACTION_UP:
         case ACTION_CANCEL:
         case ACTION_POINTER_UP:
             thisPointer = event.getPointerId(event.getActionIndex());
             if (thisPointer == primaryPointer) {
                primaryPointer = INVALIDE_POINTER_ID;
             } else if (thisPointer == seocndPointer) {
                secondPointer = INVALIDE_POINTER_ID;
             }
            break;
   }

当然 ,还可以加一些阈值判断,比如当distance大于getScaledTouchSlop,才触发使用scale的逻辑。

参考资料

Comments