一只羊的blog


Android 响应用户屏幕手势操作

现在的移动设备(手机、pad等)一般都支持多点触摸,有些设备支持十点触摸,有些支持五点触摸,有些可能只支持两点触摸。在Android平台上,用户对屏幕的操作(点按、缩放、拖动等)统称为Gesture(手势),在此我们就用“手势操作”来统称用户的这些操作。

函数实现

在程序中,如果某一控件需要响应用户手势操作、进行进一步处理时,有如下两种方法:

  1. 创建新类继承自android.view.View,覆盖父类方法public boolean onTouchEvent (MotionEvent event)
  2. 在处理这些触摸操作的类中实现接口android.view.View.OnTouchListener,该接口只有一个方法public abstract boolean onTouch (View v, MotionEvent event),并调用Viewpublic void setOnTouchListener (View.OnTouchListener l)方法对touchListener进行设置。

MotionEvent简介

用户操作信息全部在MotionEvent内,MotionEvent包含了以下信息:

  1. 当前操作的点数
  2. 每个点对应的坐标
  3. 每个点对应的手势动作

手势动作如下:

  • ACTION_DOWN 用户按下了某一点
  • ACTION_MOVE 用户按住一点在屏幕上移动
  • ACTION_UP 用户从屏幕上移开某一点对应的手指
  • ACTION_CANCEL 当前的手势操作被取消(在稍后我会详细解释此动作)
  • ACTION_OUTSIDE 手势操作发生在UI组件外
  • ACTION_HOVER_ENTER 当前点没有按下,但是进入了windowview的边界之内
  • ACTION_HOVER_EXIT 当前点没有按下,但是已经移动至windowview的边界之外
  • ACTION_HOVER_MOVE 当前点没有按下,当时点的位置发生了变化

单点手势操作

我们由浅入深,先看看单点手势操作。请参考HelloEventSingglePoint项目

在该项目中,我们创建了一个自定义View:EventView,在EventView中实现了方法public boolean onTouchEvent (MotionEvent event)

	//io.github.tianshanxuester.helloeventsinglepoing.EventView
	@Override
	public boolean onTouchEvent (MotionEvent event) {
		
		String actionString = null;
		switch(event.getAction()) {
		case MotionEvent.ACTION_CANCEL:
			actionString = "ACTION_CANCEL";
			break;
		case MotionEvent.ACTION_DOWN:
			actionString = "ACTION_DOWN";
			break;
		case MotionEvent.ACTION_MOVE:
			actionString = "ACTION_MOVE";
			break;
		case MotionEvent.ACTION_OUTSIDE:
			actionString = "ACTION_OUTSIDE";
			break;
		case MotionEvent.ACTION_UP:
			actionString = "ACTION_UP";
			break;
			default:
				actionString = "" + event.getAction();
		}
		Log.i("HelloEventSinglePoint", "Action is " + actionString);

	    return true;
	}
<!-- res/layout/activity_hello_event_single_point.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical" >

    <io.github.tianshanxuester.helloeventsinglepoint.EventView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" >
    </io.github.tianshanxuester.helloeventsinglepoint.EventView>

</LinearLayout>

运行程序后,用单个手指在屏幕上进行触摸操作,会输出日志:

  • Action is ACTION_DOWN
  • Action is ACTION_MOVE
  • Action is ACTION_MOVE… (若干)
  • Action is ACTION_UP

多点手势操作

我解释一下Android里面手势操作的基本理念,为了简单起见,以两个点A、B为例,那么下面是一个手势操作列表:

  • A点按下,产生ACTION_DOWN动作
  • B点按下,此时A点对应的动作是ACTION_MOVEB点对应的动作为ACTION_POINTER_DOWN
  • AB在屏幕上移动,产生动作为AACTION_MOVE; BACTION_MOVE
  • A或者B抬起,产生动作为A(B): ACTION_POINTER_UP; B(A):ACTION_MOVE
  • 剩下的一个点抬起,产生动作ACTION_UP

也就是说,在每一个MotionEvent事件中,最多只有一个点会产生DOWN或者UP,其他点(如果有的话)为ACTION_MOVE,读者不仅会问,为什么不存在两个点同时发出DWON或者UP的情况呢?这一点很好解释,一般来说人的动作就算是再训练有素,两个手指头按下的时间也不可能完全同步。退一步,就算两个手指头同时按下了,在系统处理层面,为了简化起见,系统也可以把同时按下的事件分解成DOWNDOWN+ACTION_MOVE两个事件传给应用程序。推而广之,更多点手势操作也是这个道理。

在多点手势操作过程中,最先发生的是ACTION_DOWN,之后其他点的按下、抬起产生的动作为ACTION_POINTER_DOWNACTION_POINTER_UP,最后一个点抬起会产生ACTION_UP。对于ACTION_DOWNACTION_UP之间的其他点,Android称之为maskedAction,可以使用函数public final int getActionMasked ()来查询这个动作是ACTION_POINTER_DOWN还是ACTION_POINTER_UP,如果getActionMasked()返回了ACTION_MOVE,则表明当前用户正在使用若干(一个或者多个)手指在屏幕上移动,没有手指按下或抬起。函数public final int getActionIndex ()用来获取当前按下/抬起的点的标识。如果当前没有任何点抬起/按下,该函数返回0

跟单点手势操作一样,多点手势操作的信息也是包含在方法onTouchEventMotionEvent参数内,MotionEvent中有如下函数可以获取多点触摸信息:

  • int getPointerCount() 手势操作所包含的点的个数
  • int getPointerId(int pointerIndex) 返回点的唯一标识(在多点手势操作中,每个手指对应点的index是不固定的,也就是说食指、中指在屏幕上移动时,某一个事件食指对应点的index为0、中指为1,而在下一个事件中可能就会变为食指对应的index为1、中指为0。所以引入了一个pointer identifier的概念,既每个点都会有一个标识,在整个手势操作中,某根手指头对应的点的标识是保证不会改变的)
  • float getX(int pointerIndex) 返回手势操作点的x坐标
  • float getY(int pointerIndex) 返回手势操作点的y坐标
  • public final int getActionMasked () 获取特殊点的

请参考项目HelloEventMultiPoint

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
41
42
43
44
45
46
	//io.github.tianshanxuester.helloeventmultipoint
	@Override
	public boolean onTouchEvent (MotionEvent event) {
		
		Log.i("HelloEventMultiPoint", "Total pointer count is " + event.getPointerCount());
		
		String actionString = actionToString(event.getAction());
		Log.i("HelloEventMultiPoint", "Main action is " + actionString);
		
		int maskedAction = event.getActionMasked();
		int pointId = event.getActionIndex();
		Log.i("HelloEventMultiPoint", "Masked action is " + actionToString(maskedAction) + "\tpointId is " + pointId);
		Log.i("HelloEventMultiPoint", "====================");
	     return true;
	}
	
	private String actionToString(int action) {
		String actionString = null;
		switch(action) {
		case MotionEvent.ACTION_CANCEL:
			actionString = "ACTION_CANCEL";
			break;
		case MotionEvent.ACTION_DOWN:
			actionString = "ACTION_DOWN";
			break;
		case MotionEvent.ACTION_MOVE:
			actionString = "ACTION_MOVE";
			break;
		case MotionEvent.ACTION_OUTSIDE:
			actionString = "ACTION_OUTSIDE";
			break;
		case MotionEvent.ACTION_UP:
			actionString = "ACTION_UP";
			break;
		case MotionEvent.ACTION_POINTER_DOWN:
			actionString = "ACTION_POINTER_DOWN";
			break;
		case MotionEvent.ACTION_POINTER_UP:
			actionString = "ACITON_POINTER_UP";
			break;
			default:
				actionString = "" + action;
		}
		
		return actionString;
	}

触发ACTION_CANCEL事件

运行上面两个例子,如果没什么差错的话,你是不会看到ACTION_CANCEL事件的,为什么呢?

要触发ACTION_CANCEL,就先得了解一个类ViewGroupViewGroup是一个放置其他views(子view)的特殊view,它是布局类(*Layout)、视图容器(ListView、GridView、HorizontalScrollView、TabHost等等很多)的基类。

也就是说ViewGroup一般是做为父视图来容纳、管理其他子视图的。既然管理,在用户手势操作过程中,就会存在父视图不希望子视图响应用户手势操作的情况。Android提供了一个函数public boolean onInterceptTouchEvent (MotionEvent ev),在用户手势操作时,系统先调用父视图(一个继承自ViewGroup的类)的这个函数,来决定当前手势操作是由父视图还是子视图来响应、处理。我们仔细看看这个函数名,函数名中有一个单词intercept,经过查词典,这个单词的中文意思是拦截。在用户的一个完整手势操作过程中(起自ACTION_DOWN,终于ACTION_UP),对于每一次的MotionEventAndroid都会调用该函数,向父视图查询是否拦截当前MotionEvent,如果父视图返回false:不拦截,则系统会调用子视图的onTouchEvent函数;如果父视图返回true:拦截,则系统调用父视图的onTouchEvent。等等,有人不禁要问了,如果在这个完整手势操作过程中,父视图初期返回false、后期返回true会是一个什么样的情况呢(捣乱的来了)?这个嘛,是这个样子的,一开始返回false,毫无疑问,子视图会被调用onTouchEvent,但凡父视图在函数onInterceptTouch中有一次返回了true,那这一完整手势操作内所有后续的MotionEvent都会调用父视图的onTouchEvent,即使父视图后期反悔而改成返回false也不行(没有后悔药)。在这种父视图先返回false,后返回true的情况下,子视图收不到后续的事件,而只是在父视图由返回false改成返回true(拦截)的时候收到ACTION_CANCEL事件。 见项目

ACTION_OUTSIDE事件

当使用WindowManager动态的显示一个Modal视图时,可以在显示视图时,指定布局参数的flags为FLAG_WATCH_OUTSIDE_TOUCH,这样当点击事件发生在这个Modal视图之外时,Modal视图就可以接收到ACTION_OUTSIDE事件,请参考项目HelloPopupWindow

//com.ingphone.hellopopupwindow.HelloPopupWindow
package com.ingphone.hellopopupwindow;

import android.os.Bundle;
import android.app.Activity;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.View;
import android.view.WindowManager;
import android.view.View.OnClickListener;
import android.view.WindowManager.LayoutParams;
import android.widget.Button;

public class HelloPopupWindow extends Activity {

	private View floatDialogView;
	
	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_hello_popup_window);
		
		Button openButton = (Button)findViewById(R.id.openWindow);
		openButton.setOnClickListener(openWindow);
		Button closeButton = (Button)findViewById(R.id.closeWindow);
		closeButton.setOnClickListener(closeWindow);
	}

	@Override
	public boolean onCreateOptionsMenu(Menu menu) {
		// Inflate the menu; this adds items to the action bar if it is present.
		getMenuInflater().inflate(R.menu.hello_popup_window, menu);
		return true;
	}

	private OnClickListener openWindow = new OnClickListener() {

		@Override
		public void onClick(View arg0) {
			if(floatDialogView != null) {
				return;
			}
			WindowManager windowManager = (WindowManager)getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
			LayoutParams params = new WindowManager.LayoutParams();
			params.type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY;
			params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;
			params.width = WindowManager.LayoutParams.WRAP_CONTENT;
			params.height = WindowManager.LayoutParams.WRAP_CONTENT;
			LayoutInflater inflater = LayoutInflater.from(HelloPopupWindow.this);
			floatDialogView = inflater.inflate(R.layout.floatwindow, null);
			Button closeButton = (Button)floatDialogView.findViewById(R.id.close);
			closeButton.setOnClickListener(new OnClickListener(){

				@Override
				public void onClick(View view) {
					WindowManager windowManager = (WindowManager)getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
					windowManager.removeView(floatDialogView);
					floatDialogView = null;
				}
				
			});
			windowManager.addView(floatDialogView, params);
		}
		
	};
	
	private OnClickListener closeWindow = new OnClickListener() {

		@Override
		public synchronized void onClick(View v) {
			if (floatDialogView == null) {
				return;
			}
			WindowManager windowManager = (WindowManager)getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
			windowManager.removeView(floatDialogView);
			floatDialogView = null;
		}};
}