本文是手势识别输入事件处理的完整学习记录。内容包括输入事件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来响应这些手势。
手势识别过程为了实现对手势的响应处理,需要理解触摸事件的表示。而识别手势的具体过程包括:
- 获得触摸事件数据。
- 分析是否匹配所支持的某个手势。
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,之后它触发对应不同手势的回调方法。
使用过程为:
- 准备GestureDetector对象,提供响应各种手势回调方法的监听器。OnGestureListener就是对不同手势的回调接口,很好理解。
// public GestureDetector(Context context, OnGestureListener listener);
mDetector = new GestureDetector(this, mGestureListener);
- 在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
- pointer移动时的速度。
有时对手势运动过程中的速度感兴趣,可以通过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;
}
}
不严谨的区分下,scroll可以分跟随手指的滑动——drag,和手指划过屏幕后的附加减速滑动——fling。
通常,需要对手势运动进行响应,比如画面跟随手指的移动而移动(平移),简单的实现就是在ACTION_MOVE中即时偏移对应的x,y,这种情况下对动作的“响应时机”是显而易见的。另一些情况下,需要达到平滑的滑动效果,但每次执行滑动的时机和滑动的增量都需要计算。比如,点击上一页,下一页按钮后执行的滚动翻页效果——类似ViewPager的动画效果那样。再一种情况是,手指快速划过屏幕后,需要让显示的内容继续滑动然后渐渐停止——fling效果。这些情况下,都需要在未来一段时间内,不断调整画面,达到滚动动画效果——每次执行滑动的时机和偏移量都需要计算。可以借助Scroller来完成“smoothly move”这样的动画效果。
推荐使用android.widget.OverScroller,它兼容性好,且支持边缘效果。和VelocityTracker一样,Scroller是一个“计算工具”,它支持startScroll、fling两个滑动效果,和上面的例子对应。从设计上,它独立于滚动效果的执行,只提供对滚动动画过程的计算和状态判定。
Scroller的使用流程:
- 准备Scroller对象。
// 在构造函数,onCreate等合适的初始化的地方
mScroller = new OverScroller(context);
- 在合适的时候开启滚动动画。一般的,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);
- 在动画的每一帧的执行时刻,计算滚动增量,应用到具体View对象。在自定义View时,可以依靠android.view.View#postOnAnimation,android.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();
}
像ScrollView,HorizontalScrollView自身提供了滚动功能,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的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的顺序
- onInterceptTouchEvent()中接收到down事件,作为后续事件的起点。
- down事件可以被childView处理,或者由当前ViewGroup的onTouchEvent()方法处理。自己处理时onInterceptTouchEvent()返回true,对应onTouchEvent()也应该返回true,这样ViewGroup就可以收到后续的事件,否则——onInterceptTouchEvent()返回true,而onTouchEvent()返回false——后续事件将交给ViewGroup的parent处理。在两个方法都返回true之后,后续事件就直接交给ViewGroup的onTouchEvent()去处理,onInterceptTouchEvent()不再收到后续事件。
- 该方法在donw事件返回false,后续所有事件,先传递到该方法,然后是给对应目标childView:的onTouchEvent()或onInterceptTouchEvent()方法——和当前ViewGroup同样的事件消耗规则。
- 方法返回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()去拦截事件。
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。
本文是手势识别输入事件处理的完整学习记录。内容包括输入事件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来响应这些手势。
手势识别过程为了实现对手势的响应处理,需要理解触摸事件的表示。而识别手势的具体过程包括:
- 获得触摸事件数据。
- 分析是否匹配所支持的某个手势。
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,之后它触发对应不同手势的回调方法。
使用过程为:
- 准备GestureDetector对象,提供响应各种手势回调方法的监听器。OnGestureListener就是对不同手势的回调接口,很好理解。
// public GestureDetector(Context context, OnGestureListener listener);
mDetector = new GestureDetector(this, mGestureListener);
- 在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
- pointer移动时的速度。
有时对手势运动过程中的速度感兴趣,可以通过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;
}
}
不严谨的区分下,scroll可以分跟随手指的滑动——drag,和手指划过屏幕后的附加减速滑动——fling。
通常,需要对手势运动进行响应,比如画面跟随手指的移动而移动(平移),简单的实现就是在ACTION_MOVE中即时偏移对应的x,y,这种情况下对动作的“响应时机”是显而易见的。另一些情况下,需要达到平滑的滑动效果,但每次执行滑动的时机和滑动的增量都需要计算。比如,点击上一页,下一页按钮后执行的滚动翻页效果——类似ViewPager的动画效果那样。再一种情况是,手指快速划过屏幕后,需要让显示的内容继续滑动然后渐渐停止——fling效果。这些情况下,都需要在未来一段时间内,不断调整画面,达到滚动动画效果——每次执行滑动的时机和偏移量都需要计算。可以借助Scroller来完成“smoothly move”这样的动画效果。
推荐使用android.widget.OverScroller,它兼容性好,且支持边缘效果。和VelocityTracker一样,Scroller是一个“计算工具”,它支持startScroll、fling两个滑动效果,和上面的例子对应。从设计上,它独立于滚动效果的执行,只提供对滚动动画过程的计算和状态判定。
Scroller的使用流程:
- 准备Scroller对象。
// 在构造函数,onCreate等合适的初始化的地方
mScroller = new OverScroller(context);
- 在合适的时候开启滚动动画。一般的,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);
- 在动画的每一帧的执行时刻,计算滚动增量,应用到具体View对象。在自定义View时,可以依靠android.view.View#postOnAnimation,android.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();
}
像ScrollView,HorizontalScrollView自身提供了滚动功能,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的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的顺序
- onInterceptTouchEvent()中接收到down事件,作为后续事件的起点。
- down事件可以被childView处理,或者由当前ViewGroup的onTouchEvent()方法处理。自己处理时onInterceptTouchEvent()返回true,对应onTouchEvent()也应该返回true,这样ViewGroup就可以收到后续的事件,否则——onInterceptTouchEvent()返回true,而onTouchEvent()返回false——后续事件将交给ViewGroup的parent处理。在两个方法都返回true之后,后续事件就直接交给ViewGroup的onTouchEvent()去处理,onInterceptTouchEvent()不再收到后续事件。
- 该方法在donw事件返回false,后续所有事件,先传递到该方法,然后是给对应目标childView:的onTouchEvent()或onInterceptTouchEvent()方法——和当前ViewGroup同样的事件消耗规则。
- 方法返回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()去拦截事件。
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。