65.9K
CodeProject 正在变化。 阅读更多。
Home

Android 中的简单手势

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.84/5 (27投票s)

2012年1月24日

CPOL

12分钟阅读

viewsIcon

286873

downloadIcon

9726

如何使用简单的触摸事件来捕获 Android 平台上的手势。

引言

本文从初学者的角度讨论了 Android 上的多点触控手势。它演示了一种允许“标准”手势(如滑动移动捏合缩放)的方法,但也努力超越这些,尝试旋转


本文的重点将是所需的数学知识以及如何捕获计算手势所需的输入,示例应用程序当然会提供一种捕获和渲染手势结果的方法。

它还将讨论为什么旋转效果不佳,至少在并非所有设备上都是如此。
本文无意成为 Android 上手势的完整指南,而是旨在帮助理解触摸事件以及如何使用它们来操作图像。


假设您已具备使用 EclipseAndroid SDK 的经验,因此我不会解释如何进行设置(有比我能提供的 好得多的解释)。

背景

在编写 Android 游戏时,我在使用多点触控手势控制游戏时遇到了一些奇怪的行为。问题源于我的 Android 设备处理多点触控事件的方式,当触点垂直或水平对齐时。


为了调查此事,我制作了一个小型应用程序,以便能够隔离测试这种行为,本文基于该应用程序。
我上传了一段 YouTube 视频,演示了我玩这个应用程序的过程,视频质量很差,对此表示歉意。

使用代码

下载项目,解压缩并导入到你的 Eclipse 工作区,我用 Pulsar 编写了它,但任何安装了相应 Android SDK 的 Eclipse 安装都可以。

要求

示例应用程序旨在完成三件事

  • 拖动移动
  • 捏合缩放
  • 旋转
我将解释所有这些背后的数学原理,但首先让我们从头开始,捕获多点触控事件。

基础知识

Activity

为了让应用程序启动并运行,我在 Eclipse 中创建了一个新的 Android 项目,并将其命名为 Gestures。由于我需要一张图片进行旋转,我将 advert.png 添加到了 res/drawable-mdpi 文件夹中,这样它就可以自动添加到我的资源中。
从应用程序的 Activity (GestureActivity) 中,我加载该图片并将其作为 Bitmap 传递给一个名为 SandboxViewView 实现。这就是 activity 所需要做的所有事情:加载资源并设置视图;

public class GesturesActivity extends Activity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.advert);
    View view = new SandboxView(this, bitmap);

    setContentView(view);
  }
}

View

SandboxView 负责使用适当的变换来渲染图像资源,同时也负责计算该变换,该变换是通过捕获该视图上的触摸事件来完成的。
为了捕获触摸事件,需要调用 setOnTouchListener(OnTouchListener) 来设置将处理事件的侦听器实例。在本文中,为了简单起见,侦听器就是视图本身,并在构造函数中进行了关联。

public class SandboxView extends View implements OnTouchListener {

  private final Bitmap bitmap;
  private Matrix transform = new Matrix();

  private Vector2D position = new Vector2D();
  private float scale = 1;
  private float angle = 0;

  public SandboxView(Context context, Bitmap bitmap) {
    super(context);
    this.bitmap = bitmap;

    setOnTouchListener(this);
  }
}

视图使用 Bitmap 进行初始化,该 Bitmap 是将被触摸事件操作的图像。一个名为 transform 的 Matrix 用于确保图像以正确的平移(或位置)、旋转和缩放进行渲染。positionscaleangle 是将被触摸事件操纵的变量,它们将作为后置变换应用于 transform

为了让 View 渲染图像,我重写了 onDraw(Canvas canvas) 而不是依赖于资源 xml 中定义的布局。这样做是因为它允许对渲染进行完全控制,无论是在渲染什么以及何时渲染(在某种程度上)。

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    Paint paint = new Paint();

    transform.reset();
    transform.postTranslate(-width / 2.0f, -height / 2.0f);
    transform.postRotate(getDegreesFromRadians(angle));
    transform.postScale(scale, scale);
    transform.postTranslate(position.getX(), position.getY());

    canvas.drawBitmap(bitmap, transform, paint);
  }

应用了四个变换来获得图像的最终变换

  • 1. 平移到(负宽度的一半,负高度的一半),这将使图像围绕(0, 0)中心,这有助于后续的旋转和缩放。
  • 2. 按从触摸事件计算的角度旋转。这必须转换为度,因为这是 postRotate 方法接受的(我喜欢使用弧度)。
  • 3. 按从触摸事件计算的缩放比例进行缩放。我在这里进行统一缩放(即垂直缩放与水平缩放相同),因为这是捏合缩放的“正常”行为,但没有理由不能让触摸事件也决定每个轴的缩放量。
  • 4. 平移到从触摸事件计算出的位置。
请记住,矩阵乘法(这里就是这样)依赖于顺序,因此重新排列它们会产生奇怪的结果。

捕获触摸事件

基础知识

Android 平台上,捕获触摸事件的一种方法是在 View 上设置 OnTouchListener,这也是我在本文中采用的方法。在 Android 上还有 其他处理手势的方法,但我选择了这种方法,因为它是一种底层的处理方式,更容易理解正在发生的事情。我认为。
如上面的 View 代码列表所示,视图本身是 OnTouchListener 的一个实现,所以 this 只是在构造函数中传递给 setTouchListener 调用。View 不一定非得是实现,但由于这是一个非常小的示例应用程序,我出于方便将其保留下来。

当调用 setTouchListener 时,触摸处理方法 boolean onTouch(View v, MotionEvent event) 将在生成触摸事件时被调用。这显然发生在用户触摸屏幕时。
传递给此方法的 MotionEvent 类型参数包含了处理手势所需的所有信息,但它不一定非常方便。格式。

触摸事件的 XY 坐标等信息都包含在内,但对于处理手势,我们更感兴趣的是运动本身,而不仅仅是当前坐标。要检测运动,我们需要跟踪当前位置和上一个位置,以便可以计算它们之间的差值。这个差值,或者说增量,告诉我们执行了什么手势。
MotionEvent 类包含历史记录(可以使用 getHistorySize 方法查询其大小,其中包含本次调用和上次调用之间发生的事件信息。这意味着,如果事件发生的比我们处理的速度快,我们仍然会知道它们,这很酷。但我们不能只依赖历史记录,因此我们需要跟踪我们自己的历史记录。

跟踪历史

我实现了一个名为 TouchManager 的辅助类,它帮助我跟踪不仅一些运动历史,还有所有当前触摸的位置和历史。请记住,我们在这里尝试实现捏合缩放,这需要我们跟踪一个,而不是两个同时发生的触摸事件,每个手指一个。
使用向量来描述点和方向等事物很容易,因此我的示例应用程序包含一个简单的二维向量实现 Vector2D。有很多关于向量、向量数学和实现向量的好文章,所以我不会在这里过多讨论。

TouchManager 的结构

触摸管理器是一个相当简单的类,旨在记录和存储当前和先前触摸的 N 个同步触摸事件。
它的构造函数初始化了基本设置

public class TouchManager {

  private final int maxNumberOfTouchPoints;

  private final Vector2D[] points;
  private final Vector2D[] previousPoints;

  public TouchManager(final int maxNumberOfTouchPoints) {
    this.maxNumberOfTouchPoints = maxNumberOfTouchPoints;

    points = new Vector2D[maxNumberOfTouchPoints];
    previousPoints = new Vector2D[maxNumberOfTouchPoints];
  }

  ...
}

一个当前点数组和一个先前触摸的数组。

这些数组中存储的数据通过以下方法公开(index 是触摸的“id”,第一个触摸获得索引 0,第二个获得索引 1,通常)

  public class TouchManager {

  // Returns true if touch index is pressed
  public boolean isPressed(int index) {
  ...
  }

  // Returns the number of current touch points
  public int getPressCount() {
  ...
  }

  // Returns the delta between current and previous touch with index 'index'
  public Vector2D moveDelta(int index) {
  ...
  }

  // The the (x, y) point for touch index
  public Vector2D getPoint(int index) {
  ...
  }

  // The the (x, y) point for previous touch index
  public Vector2D getPreviousPoint(int index) {
  ...
  }

  // The the vector that is the difference between two simultenous touches
  public Vector2D getVector(int indexA, int indexB) {
  ...
  }

  // The the vector that is the difference between two previous simultenous touches
  public Vector2D getPreviousVector(int indexA, int indexB) {
  ...
  }
}

处理 onTouch

当触摸事件发生时,事件直接通过 void update(MotionEvent event) 方法传递给 TouchManager。此方法负责检查事件并填充后端数组。

首先需要确定事件的类型,是用户按下屏幕、手指划过屏幕还是手指抬起。
此信息包含在action中,但需要一些位掩码才能使其有意义。

public void update(MotionEvent event) {
  int actionCode = event.getAction() & MotionEvent.ACTION_MASK;

  if (actionCode == MotionEvent.ACTION_POINTER_UP || actionCode == MotionEvent.ACTION_UP) {
    int index = event.getAction() >> MotionEvent.ACTION_POINTER_ID_SHIFT;
    previousPoints[index] = points[index] = null;
  }
  else {
    for(int i = 0; i < maxNumberOfTouchPoints; ++i) {
      if (i < event.getPointerCount()) {
        int index = event.getPointerId(i);

        Vector2D newPoint = new Vector2D(event.getX(i), event.getY(i));

        if (points[index] == null)
          points[index] = newPoint;
        else {
          if (previousPoints[index] != null) {
          previousPoints[index].set(points[index]);
        }
        else {
          previousPoints[index] = new Vector2D(newPoint);
        }

        // Sanity check, if it moves by too much then ignore it
        if (Vector2D.subtract(points[index], newPoint).getLength() < 64)
          points[index].set(newPoint);
        }
      }
      else {
        previousPoints[i] = points[i] = null;
      }
    }
  }
}  

这基本上就是记录触摸事件的内容。此时,我们知道用户手指的 (X, Y) 坐标以及手指之前在屏幕上的位置,利用这些信息,我们可以开始确定如何相应地移动屏幕上的内容。

手势

拖动移动

拖动移动是最简单的手势,因为它只需要一个手指。本质上,当用户在屏幕上拖动单个手指时,我们希望内容以相同的距离和相同的方向移动。
如下图所示

Drag.png

蓝色方块是屏幕,绿色方块是内容。绿色圆圈是当前触摸,红色圆圈是上一个触摸。
如图所示,我们希望将绿色方块移动(或平移)我们想要移动的距离,即第一个和第二个触摸点构成的向量之差。
如果当前点是(2, 2),上一个点是(5, 5),那么我们将需要添加

(2, 2) - (5, 5) = (-3, -3)

到内容的当前位置。那么这意味着,如果内容位于位置(3, 3),那么它的新位置将是

(3, 3) + (-3, -3) - (0, 0).

执行这种简单数学运算所需的方法在 Vector2D 实现中可用。

简单。这就是拖动移动

onTouch 方法中,在 TouchManager 中记录触摸信息后,此信息应用于内容的位置,前提是只有一个手指当前按下。

public boolean onTouch(View v, MotionEvent event) {
  try {
    touchManager.update(event);

    if (touchManager.getPressCount() == 1) {
      position.add(touchManager.moveDelta(0));
    }
    else {
      if (touchManager.getPressCount() == 2) {
        ...
      }
    }

    invalidate();
  }
  catch(Throwable t) {
    // So lazy...
  }
  return true;
}

调用 moveDelta 方法并传入 0 作为参数,这意味着我们要求获取第一个(也是唯一一个手指)的信息。

捏合缩放

捏合缩放稍微复杂一些,因为它涉及两个手指,但并不复杂多少,仍然主要关注移动增量。
当两个手指捏合或“展开”时,我们需要弄清楚如何以及然后将该增量作为比例应用于内容,如下图所示。

Pinch.png

在左侧的图中,绿色是第一个触摸的位置,蓝色是第二个触摸的位置。在右侧的图中,绿色和蓝色分别是第一个和第二个触摸的当前位置,而红色和紫色分别是第一个和第二个触摸的原始位置。

放大或缩小的量可以通过查看构成两个触摸点之间距离的向量长度的相对差异来计算。这意味着,在捏合缩放中,我们需要计算两个距离

PinchDistance.png

此图中的白线代表手指首次触摸屏幕时的距离,而黑线代表手指分开移动后的距离。

计算这两个向量只需从一个位置减去另一个位置(这是我们在拖动移动手势中已经做过的操作)。计算出两个向量后,它们的长度之商就是我们需要应用于当前比例的比例因子。
得到;

白向量;PrevPos1 - PrevPos2 = PrevDeltaVec

黑向量;CurrentPos1 - CurrentPos2 = CurrentDeltaVec

比例调整;Scale = Scale * length(CurrentDeltaVec) / length(PrevDeltaVec)

或者,用代码表示(其中 scaleSandBoxView 的成员变量);

  Vector2D current = touchManager.getVector(0, 1);
  Vector2D previous = touchManager.getPreviousVector(0, 1);
  float currentDistance = current.getLength();
  float previousDistance = previous.getLength();

  // Guard against division by zero
  if (previousDistance != 0) {
    scale *= currentDistance / previousDistance;
  }

因此,如果第一个距离是34,第二个距离是64(当前比例是1.0),那么新的比例是1.0 * 64 / 32 = 1.0 * 2 / 1 = 2.0。增加比例会放大。
同样,大部分数学运算隐藏在 TouchManagerVector2D 类中。

旋转

对于旋转,有很多与捏合缩放相同的地方;使用两个手指,我们检查的不是触摸的绝对位置,而是当前和先前触摸之间的增量。

Rotate.png

在这些图中,是的,我知道我不太擅长绘制图表,颜色与捏合缩放图中的含义相同。

我们正在寻找的是由将一个触摸位置减去另一个位置得到的向量之间的角度变化。
这意味着我们想要第一个手指位置减去第二个手指位置得到的向量,与先前位置执行相同操作得到的向量之间的角度。

RotateAngle.png

让黑白向量代表与捏合缩放图相同的含义,我们想要它们之间的角度,用黄色弧标记。现在,您可能会争辩说两个向量之间不仅仅有一个角度,而是有两个角度,一个小于 180 度,一个大于 180 度。
我们总是想要较小的一个,因为这是最可能的情况。

欧几里得向量数学告诉我们,两个向量的点积等于它们之间角度的余弦乘以它们长度的乘积。由此我们可以推导出角度,因为我们同时拥有长度和点积。这就是我首先尝试的方法,但该方法不起作用,因为它只给出角度的幅度,没有符号(或者说有,但总是正的)。这意味着我们无法区分顺时针旋转和逆时针旋转。

为了找到两个向量之间的有符号距离,我们可以使用反正切函数的变体atan2
对于归一化向量 AB,它们之间的有符号角度为;

deltaAngle = atan(B.y, B.x) - atan(A.y, A. x)

public class Vector2D {

  ...

  public static Vector2D getNormalized(Vector2D v) {
    float l = v.getLength();
    if (l == 0)
      return new Vector2D();
    else
      return new Vector2D(v.x / l, v.y / l);
  }

  public static float getSignedAngleBetween(Vector2D a, Vector2D b) {
    Vector2D na = getNormalized(a);
    Vector2D nb = getNormalized(b);

    return (float)(Math.atan2(nb.y, nb.x) - Math.atan2(na.y, na.x));
  }
}

这意味着要获得新的旋转角度,只需将当前角度加上这个增量角度。

关注点

在设备上我尝试了这一点,你需要一个实体设备来尝试,因为模拟器不支持多点触控,硬件在捕获多个触摸时不是很好。当手指水平或垂直对齐时尤其糟糕。在YouTube 视频中展示示例应用程序时,这一点非常明显。

我相信这主要是由于视频中设备的硬件性能,因为我在较新的设备上尝试过该代码,并且运行良好(至少好很多)。但像这样的限制在开发移动应用程序时非常重要,无论何时决定深入研究 API 的简单部分,在各种设备上测试应用程序变得越来越重要。这很像 Web 开发工作需要在多个浏览器中进行测试。

对视频质量差表示歉意。它是用 Canon IXUS 9015 拍摄的。我手持拍摄,同时进行手势。光线不足。没有声音,因为我的孩子刚睡着 :)

一如既往,非常欢迎任何评论。

历史

  • 2012-01-24;第一个版本
© . All rights reserved.