UI篇 第五章 自定义控件 - 输入事件

  本章将介绍一下Android中的各类输入事件。
  本章主要参考书籍:《Android开发艺术探索》《Android群英传》,同时加上了笔者自己的体会。

第一节 基础知识

  我们先来介绍两个基础知识。


事件类型
  在AndroidView类支持监听如下五种输入事件,我们可以通过设置监听器来监听事件:

-  点击事件:当用户点击一个View(如Button)时,系统会产生点击事件,并传递给该View。
   -  调用View的setOnClickListener方法来监听此事件。
-  长按事件:当用户长时间按住一个View时,系统会产生长按事件,并传递给该View。
   -  调用View的setOnLongClickListener方法来监听此事件。
-  焦点改变事件:当用户使用导航键或滚迹球将输入焦点导入或导出某个View时,系统会产生焦点改变,并传递给该View。
   -  调用View的setOnFocusChangeListener方法来监听此事件。
-  按键事件:当用户让输入焦点落到某个View上,并且按下或释放设备上的一个按键时,系统会产生按键事件,并传递给该View。
   -  调用View的setOnKeyListener方法来监听此事件。
-  触摸事件:当用户手指触摸某个View时,系统会产生触摸事件,并传递给该View。
   -  调用View的setOnTouchListener方法来监听此事件。


  比如,下面代码展示了如何给一个Button注册一个View.OnClickListener监听器:

1
2
3
4
5
6
Button button = (Button) findViewById(R.id.button_send);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// Do something in response to button click
}
});

  你可能也发现把OnClickListener作为Activity的一部分来实现会更方便。这样会避免类的加载和对象空间的分配。如:

1
2
3
4
5
6
7
public class ExampleActivity extends Activity implements OnClickListener {
protected void onCreate(Bundle savedValues) {
Button button = (Button)findViewById(R.id.corky);
button.setOnClickListener(this);
}
public void onClick(View v) { }
}


  如果我们想监听系统内置控件的事件,那么只能使用上面这种调用setXxx设置监听器。但是,若控件是我们自己创建的,那就可以通过重写下面的方法来监听事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 当一个键被按下时,会调用这个方法;
onKeyDown(int, KeyEvent)

// 当一个被按下的键弹起时,会调用这个方法;
onKeyUp(int, KeyEvent)

// 当轨迹球滚动时,会调用这个方法;
onTrackballEvent(MotionEvent)

// 当一个View对象获得或失去焦点时,会调用这个方法。
onFoucusChanged(Boolean, int, Rect)

// 触摸事件
onTouchEvent(MotionEvent event)


  上面列出的五种事件中,相对来说触摸事件稍显复杂,本章会重点介绍触摸事件。


触摸模式
  对于一个有触摸能力的设备,一旦用户触摸屏幕,这个设备就会进入触摸模式(touch mode)
  任何时刻,只要用户点击了一个方向键(比如Android电视的遥控器)或滚动了鼠标滚轮,设备就会退出触摸模式,同时系统会查找一个需要焦点的View对象,并给予其焦点(高亮显示)。
  触摸模式状态是被整个系统管理的,我们可以调用View#isInTouchMode()来查看设备当前是否是触摸模式。

第二节 触摸事件

  触摸事件在开发中是最常见的,也是最容易让人搞混的,因此从本节开始将详细介绍触摸事件。

滑动位置

  在开发中,比较常见的一个需求:让View能随着用户的手指而拖动,要实现这个功能就需要监听View的触摸事件。

  示例代码:

1
2
3
4
5
6
Button button = (Button) this.findViewById(R.id.img);
button.setOnTouchListener(new View.OnTouchListener() {
public boolean onTouch(View v, MotionEvent event) {
return false;
}
});


  为了了解onTouch方法,我们先来看看View.OnTouchListener接口:

1
2
3
4
5
6
//  描述:当View被用户“触摸”时,会调用此回调方法。
// 参数:
// v: 被触摸的组件。
// event: 表示一个触摸事件,其内封装了与“触摸事件”有关的数据。如:用户手指在屏幕的X、Y坐标等。
// 返回值:用于告知Android系统,当前事件是否被成功处理。
public abstract boolean onTouch(View v, MotionEvent event)

  其中MotionEvent类用来表示“触摸事件”,触摸事件有如下三个常见的状态:

-  ACTION_DOWN:表示手指按在了View上。
-  ACTION_MOVE:表示手指按下后(此时手指没有抬起),接着在View上拖动手指。
-  ACTION_UP:表示手指从View上抬起。

  正常情况下,一次手指触摸屏幕的行为会触发一系列的触摸事件,最常见的是如下两种情况:

-  点击屏幕后立刻松开,事件序列为:ACTION_DOWN -> ACTION_UP。
-  点击屏幕后滑动一会再松开,事件序列为:ACTION_DOWN -> ACTION_MOVE -> …… -> ACTION_MOVE -> ACTION_UP。

  在继续向下进行之前,先介绍一个名词“事件序列”

事件序列
  同一个事件序列是指从手指接触屏幕的那一刻起,到手指离开屏幕的那一刻结束,在这个过程中所产生的一系列事件。通常这个事件序列以ACTION_DOWN事件开始,中间含有数量不定的ACTION_MOVE事件,最终以ACTION_UP事件结束。


  范例1:MotionEvent类的常用方法:

1
2
3
4
5
6
7
8
9
10
11
//  描述:获取当前产生的事件的类型,常见的取值有:ACTION_DOWN、ACTION_MOVE、ACTION_UP。
public final int getAction();

// 当在View产生了MotionEvent事件时,这两个方法可以获取用户手指相对于该View的左上角坐标的偏移量。
public final float getX();
public final float getY();

// 当在View产生了MotionEvent事件时,这两个方法可以获取用户手指相对于屏幕左上角坐标的偏移量。
// 屏幕左上角就是状态栏的左上角的那个点。
public final float getRawX();
public final float getRawY();


  最后,下面给出一个完整的范例,如果你感觉看不懂,那就请去阅读其它人的教程,学会了触摸事件后,再回来继续。


  范例2:通过getX()getY()移动按钮。

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
public class MainActivity extends Activity {

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

final Button button = (Button) findViewById(R.id.btn);
button.setOnTouchListener(new View.OnTouchListener() {
private int lastX, lastY;

public boolean onTouch(View v, MotionEvent event) {
int x = (int) event.getX(); // 获取手指在Button上的位置。
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = x; // 保存手指按下时的位置。
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
// 调用layout方法更新View的位置。
button.layout(button.getLeft() + offsetX, button.getTop() + offsetY,
button.getRight() + offsetX, button.getBottom() + offsetY);
break;
}
return false;
}
});
}
}

语句解释:
-  通过本范例看出,我们可以手工调用View的layout方法来更新位置,在其内部会调用invalidate进行重绘。
-  需要注意的是本范例中,只有当手指按下的时候才会保存位置,手指移动时并不会。


  范例3:通过getRawX()getRawY()移动按钮。

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
public class MainActivity extends Activity {

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

final Button button = (Button) findViewById(R.id.btn);
button.setOnTouchListener(new View.OnTouchListener() {
private int lastX, lastY;

public boolean onTouch(View v, MotionEvent event) {
int x = (int) event.getRawX(); // 获取手指在屏幕上的位置。
int y = (int) event.getRawY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastX;
int offsetY = y - lastY;
button.layout(button.getLeft() + offsetX, button.getTop() + offsetY,
button.getRight() + offsetX, button.getBottom() + offsetY);
// 此处需要保存x、y的值。
lastX = x;
lastY = y;
break;
}
return false;
}
});
}
}

语句解释:
-  再次强调本范例与范例2的区别,本范例中在手指移动的时候需要保存位置,具体原因请自己思考。
-  提示:                要啥提示?动动脑子吧。


  也可以通过修改View的LayoutParams来改变View的位置,只需要把范例3的第22行代码替换为:

1
2
3
4
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) button.getLayoutParams();
params.leftMargin += offsetX;
params.topMargin += offsetY;
button.setLayoutParams(params);


滑动内容

  在Android中,对于一个View来说它有两种类型滑动:

-  第一种,View本身的位置发生变化(即上面一节介绍的知识)。
-  第二种,View的内容发生变化。
   -  比如当LinearLayout的子元素的尺寸超过了LinearLayout的尺寸,那么超出的部分默认是无法显示的。
   -  不过Android中所有的View的内容都是可以滑动的,也就是说可以通过滑动LinearLayout的内容,来让被隐藏的部分显示出来。

  本节就是来介绍如何滑动View的内容。


使用scrollTo和scrollBy方法
  为了实现View内容的滑动,View类提供了专门的方法来实现这个功能,那就是scrollToscrollBy,它们的源码为:

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
/**
* Set the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}

/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}

  可以看出来,其中scrollBy转调用了scrollTo方法,它实现了基于当前位置的相对滑动,而scrollTo则实现了基于所传递参数的绝对滑动。


  使用范例,如下所示:

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
public class MyView extends View {

public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
// 每次点击时,都使当前View的内容,在x轴方向滑动30像素。
setOnClickListener(new OnClickListener() {
public void onClick(View v) {
scrollBy(30, 0);
}
});
}

@Override
protected void onDraw(Canvas canvas) {
Paint paint = new Paint();
paint.setColor(Color.WHITE);
StringBuilder sub = new StringBuilder();
sub.append("11111111111111111111111111111111");
sub.append("22222222222222222222222222222222");
sub.append("33333333333333333333333333333333");
sub.append("44444444444444444444444444444444");
sub.append("55555555555555555555555555555555");
canvas.drawText(sub.toString(), 0, 100, paint);
}
}

语句解释:
-  有两点需要注意:
   -  第一,scrollBy和scrollTo滑动的是View的内容,而不是View本身的位置。
   -  第二,scrollBy和scrollTo滑动是瞬间完成的,没有滚动时的滑翔效果。
-  调用View类的getScrollX()和getScrollY()方法可以获取View的滚动条的当前位置。


Scroller
  使用scrollByscrollTo的滑动是瞬间完成的,效果比较生硬,为了给用户流畅的体验,可以把一次大的滑动分成若干个小的滑动,并在若干时间内完成。
  我们通过Scroller类就可以实现动画滑动的任务。


  修改后的范例,如下所示:

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
public class MyView extends View {

public MyView(Context context) {
super(context);
setBackgroundColor(Color.BLACK);
setOnClickListener(new OnClickListener() {
public void onClick(View v) {
// 第一步,先为Scroller对象设置滚动参数。
// 参数依次为:滚动条当前X轴位置、Y轴位置、X轴位移长度、Y轴位移长度、多少毫秒内完成滚动。
mScroller.startScroll(getScrollX(), getScrollY(), 30, 0, 1000);
// 第二步,设置完参数后,调用invalidate方法,触发View的重绘。
invalidate();
}
});
}

private Scroller mScroller = new Scroller(getContext());

// 当View被重绘时,系统会回调View类的此方法,计算滚动条的当前位置。
@Override
public void computeScroll() {
// 方法computeScrollOffset会依据时间的流逝,来计算Scroller当前所处的位置。
if (mScroller.computeScrollOffset()) {
// 让当前View的滚动条,滚动到Scroller对象当前的位置。
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
// 再次出发重绘,直到Scroller对象滚动到终点(即computeScrollOffset返回false)才停止。
// 这样一来,就实现了动画滚动的效果了。
postInvalidate();
}
}

@Override
protected void onDraw(Canvas canvas) {
Paint paint = new Paint();
paint.setColor(Color.WHITE);
StringBuilder sub = new StringBuilder();
sub.append("11111111111111111111111111111111");
sub.append("22222222222222222222222222222222");
sub.append("33333333333333333333333333333333");
sub.append("44444444444444444444444444444444");
sub.append("55555555555555555555555555555555");
canvas.drawText(sub.toString(), 0, 100, paint);
}
}

语句解释:
-  Scroller的startScroll方法里面什么都没有做,只是记录了一下传递过来的参数。
-  Scroller对象只是用来协助计算滚动条的位置的,它本身无法使View的内容滚动,它需要和View类的computeScroll、scrollTo、scrollBy方法配合使用。


  另外,Android3.0中提出的属性动画也可以完成Scroller的功能,具体请参阅《媒体篇 第三章 动画》

高级用法


TouchSlop
  TouchSlop是系统所能识别出的被认为是滑动的最小距离。换句话说,当手指在屏幕上滑动时,如果两次滑动之间的距离小于这个值,那么系统就不认为它是滑动。

  通过下面的代码可以获取这个值,返回值的单位是px

1
ViewConfiguration.get(getApplicationContext()).getScaledTouchSlop()

  我们在处理滑动时,可以利用它来做一些过滤,即滑动距离小于这个值时就不认为是滑动,这样可以有更好的用户体验。


VelocityTracker
  速度追踪器(VelocityTracker)用于追踪手指在屏幕上的滑动速度,它的使用方法很简单:
 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ******* 第一步,获取一个VelocityTracker对象:
VelocityTracker mTracker = VelocityTracker.obtain();

// ******* 第二步,在onTouchEvent方法中添加如下代码,记录每一个触摸事件:
mTracker.addMovement(event);

// ******* 第三步,在ACTION_UP事件发生时,使用如下执行计算操作。
// 以当前mTracker对象中收集的所有MotionEvent对象为基础,计算出手指1秒所能滑动的像素数量,并将它们保存起来。
mTracker.computeCurrentVelocity(1000);

// ******* 第四步,获取上面计算出的速度:
mTracker.getXVelocity(); // 水平方向。
mTracker.getYVelocity(); // 垂直方向。

// ******* 第五步,释放资源:
mTracker.recycle();
mTracker = null;


  完整的范例,如下所示:

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
public class MyView extends View {

private VelocityTracker mTracker;

public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
if (mTracker == null) {
mTracker = VelocityTracker.obtain();
}
mTracker.addMovement(event);
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
mTracker.computeCurrentVelocity(1000);
String message = "不算滑动";
if (Math.abs(mTracker.getXVelocity()) >= 50) {
message = (mTracker.getXVelocity() > 0 ? "从左到右滑动" : "从右到左滑动");
}
Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
mTracker.recycle();
mTracker = null;
break;
}
return true;
}
}

语句解释:
-  本范例只是演示VelocityTracker的使用方法,更实用的案例后面会介绍。


GestureDetector
  通过重写onTouchEvent方法来实现一些复杂的手势(比如双击、长按等)会很麻烦。
  幸运的是,Android SDK给我们提供了一个手势识别的类——GestureDetector,通过这个类我们可以识别很多的手势。

  它的使用方法也很简单,直接看代码吧:

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
public class MyView extends View {

private GestureDetector mGestureDetector;

private GestureDetector.OnGestureListener onGestureListener =
new GestureDetector.SimpleOnGestureListener() {
public void onLongPress(MotionEvent e) {
// 当手指长按时回调此方法。
}
};

private GestureDetector.OnDoubleTapListener onDoubleTapListener =
new GestureDetector.SimpleOnGestureListener() {
public boolean onSingleTapConfirmed(MotionEvent e) {
// 当单击时回调此方法。
// 与onSingleTapUp的区别在于,如果触发了onSingleTapConfirmed,那么后面不可能再紧跟着另一个单击行为。
// 也就是说,这只可能是单击行为,而不可能是双击中的一次单击。
return false;
}
public boolean onDoubleTap(MotionEvent e) {
// 当双击时回调此方法。
return false;
}
};

public MyView(Context context, AttributeSet attrs) {
super(context, attrs);
// 创建GestureDetector对象。
mGestureDetector = new GestureDetector(getContext(), onGestureListener);
// 设置双击事件监听器。
mGestureDetector.setOnDoubleTapListener(onDoubleTapListener);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
// 将当前View的触摸事件托管给GestureDetector处理。
mGestureDetector.onTouchEvent(event);
return true;
}
}

语句解释:
-  SimpleOnGestureListener和SimpleOnGestureListener类里还有其它方法,请自行查看。
-  需要说明的是,若你需要监听双击事件的话就用GestureDetector吧,否则还是自己处理触摸事件比较好。


本节参考阅读:

第三节 事件分发机制

  本节将以触摸事件为范例,从源码的角度进行分析,详细说明事件的分发机制。

Activity的事件分发

  上一章我们已经分析过了,当一个事件产生时,它的传递过程,现在我们在它基础上再次扩展一下,最终的顺序为:

WMS -> ViewRootImpl -> DecorView -> Activity -> Window -> DecorView

  即当事件传递给Activity后,Activity会转交给WindowWindow再传递给DecorView


  在Activity类中定义了如下几个方法,当对应的事件发生时,系统会调用它们:

1
2
3
4
5
6
7
8
// 当触摸事件发生时,系统回调此方法。
public boolean dispatchTouchEvent(MotionEvent ev);

// 当按键事件发生时,系统回调此方法。
public boolean dispatchKeyEvent(KeyEvent event);

// 当轨迹球事件发生时,系统回调此方法。
public boolean dispatchTrackballEvent(MotionEvent ev);


  既然是以触摸事件为范例,那么我们就从Activity的dispatchTouchEvent方法开始分析:

1
2
3
4
5
6
7
8
9
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}

  从代码中可以看到,事件会被交给Activity的Window对象的方法superDispatchTouchEvent方法进行分发处理:

-  若该方法返回true则说明事件被某个控件处理了,那么Activity就认为这个事件已经结束了,直接返回即可。
-  若该方法返回false则说明事件没人处理,那么Activity就是把事件交给它的onTouchEvent方法去处理。

  提示:你可以通过重写ActivitydispatchTouchEvent方法且不调用“super.dispatchTouchEvent()”来拦截所有的触摸事件。


  上一章我们说了,Window类的唯一子类就是PhoneWindow类,因此我们接着看它的superDispatchTouchEvent方法:

1
2
3
public boolean superDispatchKeyEvent(KeyEvent event) {
return mDecor.superDispatchKeyEvent(event);
}

  发现它只是转调用了DecorView类的方法,继续深入:

1
2
3
4
public boolean superDispatchTouchEvent(MotionEvent event) {
// 只是简单的调用了父类的实现。
return super.dispatchTouchEvent(event);
}

  由于DecorView继承自FrameLayout,此时事件就由Activity传到View手中了。

ViewGroup的事件分发

  当事件传递到DecorView手中时,一切才刚刚开始而已,后面还有很多步骤要执行。
  接着上面的分析,由于在DecorViewFrameLayout类中都没有dispatchTouchEvent方法的定义,所以我们只能继续去上级父类中找,最终在ViewGroup类中找到了该方法。
  不过由于该方法太长,所以为了看的清晰,我们下面将会分段来分析。


拦截事件

  我们知道每个MotionEvent都有一个坐标点,当触摸事件传递到ViewGroup手中时,默认情况下ViewGroup会遍历它的所有子View,若该坐标点正好处于某个子View的范围内,则就将触摸事件转发给这个子View去处理。
  不过,这个默认行为是可以改变,即ViewGroup可以将事件拦截下来留给自己处理,而不把事件传递给子View

  首先我们来看一下dispatchTouchEvent方法的这段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}

  上面的代码就是ViewGroup用来判断是否需要拦截触摸事件的,可以看出ViewGroup在如下两种情况时会判断是否拦截当前事件:

1
actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null

  前者很好理解,但mFirstTouchTarget是什么呢?
  其实等我们看到后面的代码时就会知道,当ACTION_DOWN事件由ViewGroup的某个子元素成功处理时,mFirstTouchTarget就会被赋值并指向那个子元素。

  当上述的两个条件满足其一时,并且第5行代码也返回false时,就会调用ViewGroup类的onInterceptTouchEvent方法:

-  ViewGroup的子类可以重写onInterceptTouchEvent方法,用来决定当前ViewGroup是否拦截本次触摸事件:
   -  若重写方法时返回true,则本次的触摸事件将由当前ViewGroup处理,不会再传递给子View了。
   -  若重写方法时返回false,则表示本次的触摸事件当前ViewGroup将不拦截,事件的传递机制一切照旧。
-  当需要处理滑动冲突时,就可以重写此方法,并依据实际情况返回不同的值,该方法默认返回false。

  上面第5行代码用来获取当前ViewGroup是否开启了“禁止拦截事件”的功能,若开启了,则ViewGroup就无法拦截事件了,可以使用requestDisallowInterceptTouchEvent方法可以修改这个状态。


  总结一下这段代码的价值:

-  onInterceptTouchEvent方法在ViewGroup中定义,用来决定ViewGroup是否拦截事件。
-  onInterceptTouchEvent方法不是每次都调用,如果想提前处理事件,应重写dispatchTouchEvent方法。
-  requestDisallowInterceptTouchEvent方法在ViewGroup中定义,能禁止ViewGroup拦截事件。


分发事件

  上面的代码用来确定ViewGroup是否需要拦截事件,接下来就分别看一下这两种情况。

  当ViewGroup不拦截事件的时候,事件会向下分发交给它的子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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = customOrder
? getChildDrawingOrder(childrenCount, i) : i;
final View child = (preorderedList == null)
? children[childIndex] : preorderedList.get(childIndex);

// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}

if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}

newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}

resetCancelNextUpFlag(child);
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}

// The accessibility focus didn't handle the event, so clear
// the flag and do a normal dispatch to all children.
ev.setTargetAccessibilityFocus(false);
}

  上面这段代码逻辑也很清晰,首先遍历ViewGroup的所有子元素,然后判断子元素是否能够接收这个事件,判断的依据有两个:

-  !canViewReceivePointerEvents(child):子元素是否在执行动画。
-  !isTransformedTouchPointInView(x, y, child, null):事件的坐标是否落在了子元素的区域内。

  如果某个元素满足这两个条件,那么就会接着调用dispatchTransformedTouchEvent方法将触摸事件传递该元素。

  接着查看dispatchTransformedTouchEvent的源码,发现该方法中出现多次类似的if判断:

1
2
3
4
5
6
7
if (child == null) {
// 此时ViewGroup会调用继承自View类的方法,来自己处理事件。
handled = super.dispatchTouchEvent(event);
} else {
// 由子View去处理事件。
handled = child.dispatchTouchEvent(event);
}

  可以看到不管child是否为null,这段代码最终都会调用dispatchTouchEvent方法来处理事件。

  那么child是什么呢,它又何时为null呢?
  child就是用来处理本次触摸事件的控件,当ViewGroup拦截了事件时,也会调用dispatchTransformedTouchEvent方法处理事件,只不过child的值会传递为null。源码如下:

1
handled = dispatchTransformedTouchEvent(ev, canceled, null, TouchTarget.ALL_POINTER_IDS);

  到此我们就清楚了:

-  若ViewGroup没有拦截事件,则会继续将事件分发给子View处理:
   -  若某个子View能处理这个事件,则会调用该子View的dispatchTouchEvent方法进行处理。
   -  若for循环结束后,没有任何一个子View能处理这个事件,则ViewGroup会自己进行处理。
-  若ViewGroup拦截了事件,则它也会自己处理这个事件。
-  当需要ViewGroup自己来处理事件时,ViewGroup会调用继承自View类的dispatchTouchEvent方法来处理。


  还有一点要知道,当子View的dispatchTouchEvent方法返回true时,意味着这个事件被处理了,上面的第51行代码就会被执行,然后跳出for循环:

1
newTouchTarget = addTouchTarget(child, idBitsToAssign);

  其实mFirstTouchTarget真正的赋值过程是在addTouchTarget内完成的:

1
2
3
4
5
6
private TouchTarget addTouchTarget(View child, int pointerIdBits) {
TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}

  相应的,ACTION_DOWN之后的事件都会直接传递给mFirstTouchTarget处理,因为for循环寻找能处理事件的子View的过程只在ACTION_DOWN时才会触发。

  至此我们就得出了一个结论了,不论事件最终是由ViewGroup类处理,还是由某个子View处理,程序最终都会调用View类的dispatchTouchEvent方法,接下来我们就来看一下这个方法。

View的事件分发

  View对点击事件的处理过程稍微简单一些,因为它没有子元素不需要向下传递事件,所以它需要处理自己的事件。

  先看它的dispatchTouchEvent方法,如下所示:

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
public boolean dispatchTouchEvent(MotionEvent event) {
// 此处省略若干代码...

boolean result = false;

// 此处省略若干代码...

if (onFilterTouchEventForSecurity(event)) {
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}

if (!result && onTouchEvent(event)) {
result = true;
}
}

// 此处省略若干代码...

return result;
}

  上面代码很简单,分两种方式处理触摸事件:

-  第一种,若当前View处于可用状态,且设置了OnTouchListener,则调用监听器的onTouch方法处理事件。
-  第二种,若第一种方式未能成功处理事件,则调用自己的onTouchEvent方法来处理。
   -  让OnTouchListener优先于onTouchEvent的好处是,方便在外界处理触摸事件。


  OnTouchListener的应用场景:

我们使用ScrollView来包含一些控件,同时要求程序可以动态的控制ScrollView是否能滚动。即:
-  在手机横屏的时候,允许它滑动。
-  在手机竖屏的时候,不许它滑动。

  示例代码:

1
2
3
4
5
6
7
8
// 此处设置的OnTouchListener会优先于ScrollView本身的onTouchEvent方法执行。
mScrollView.setOnTouchListener(new View.OnTouchListener(){
public boolean onTouch(View v, MotionEvent event) {
// 若当前是竖屏状态,则直接返回true,即不需要在执行ScrollView的onTouchEvent方法了。
// ScrollView执行滑动的代码是写在onTouchEvent方法中的,该方法不被调用的话,也就没法滑动了。
return isShuPing ? true : false;
}
});


  接下来在看一下onTouchEvent方法的源码,由于代码比较长,我们同样分块来看,首先是这段:

1
2
3
4
5
6
7
8
9
10
if ((viewFlags & ENABLED_MASK) == DISABLED) {
if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {
setPressed(false);
}
// A disabled view that is clickable still consumes the touch
// events, it just doesn't respond to them.
return (((viewFlags & CLICKABLE) == CLICKABLE
|| (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
|| (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE);
}

  从上面的代码中可以看出,不可用状态下的View照样会消耗事件。

  接着,如果View设置了代理,那么还会执行TouchDelegateonTouchEvent方法,代理的工作机制和OnTouchListener,这里就不再细说了。

1
2
3
4
5
if (mTouchDelegate != null) {
if (mTouchDelegate.onTouchEvent(event)) {
return true;
}
}

  下面再看一下onTouchEvent中对点击事件的具体处理,如下所示:

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
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:

// 此处省略若干代码...

if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}

// 此处省略若干代码...
}

return true;
}

  从上面的代码来看,只要View的CLICKABLE、LONG_CLICKABLE和CONTEXT_CLICKABLE有一个为true,那么它就会消耗这个事件,即onTouchEvent方法将返回true。

-  View的LONG_CLICKABLE默认为false。
-  View的CLICKABLE是否为false与具体的View类有关,比如Button是可以点击的,TextView是不可点击的。

  同时,当ACTION_UP事件发生时,会触发performClick方法,如果View设置了OnClickListener,那么performClick方法内部会调用它的onClick方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}

sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}

  至此,触摸事件的分发过程的源码分析已经结束了,接下来将利用所学的知识,来处理滑动冲突的问题。

第四节 实战

  本节开始综合前面所学的知识。

自定义ScrollView

  现在有个需求,创建一个ViewGroup控件,可以通过滑动来在多个子View之间切换,效果和ViewPager类似。

  代码:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class MyScrollView extends LinearLayout {

private Scroller mScroller = new Scroller(getContext());
private VelocityTracker mTracker;
private int mTouchSlop;
private int mLastX;
private int mChildIndex;

public MyScrollView(Context context) {
super(context);
setOrientation(HORIZONTAL);
mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
}

@Override
public boolean onTouchEvent(MotionEvent event) {
if (mTracker == null) {
mTracker = VelocityTracker.obtain();
}
mTracker.addMovement(event);
int currX = (int) event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 如果当前正在播放动画,则停止它,这样能提供更好的用户体验。
// 当然也可以把这三行代码注释掉,注释后的效果请自行体验。
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
// 在用户手指滑动的同时滚动内容,这样就模仿了ViewPager随着手指滚动的效果。
scrollBy(mLastX - currX, 0);
break;
case MotionEvent.ACTION_UP:
mTracker.computeCurrentVelocity(500);
if (Math.abs(mTracker.getXVelocity()) >= mTouchSlop) {
if (getChildCount() == 0) {
mChildIndex = 0;
} else {
if (mTracker.getXVelocity() > 0) {
//从左到右滑动
mChildIndex = (mChildIndex - 1 < 0 ? 0 : mChildIndex - 1);
} else { //从右到左滑动
mChildIndex = (mChildIndex + 1 > getChildCount() - 1 ? getChildCount() - 1 : mChildIndex + 1);
}
}
}
mTracker.recycle();
mTracker = null;
// 当手指抬起的时候,开始播放滚动动画,从当前位置开始,到最近的一个子View结束。
mScroller.startScroll(getScrollX(), 0, mChildIndex * getChildAt(0).getWidth() - getScrollX(), 0, 1000);
postInvalidate();
break;
}
mLastX = currX;
return true;
}

@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
}

  Activity的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MainActivity extends Activity {

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

DisplayMetrics dm = getResources().getDisplayMetrics();
MyScrollView scrollView = new MyScrollView(this);
int[] colorls = new int[]{Color.BLUE, Color.CYAN, Color.YELLOW};
for (int i = 0; i < colorls.length; i++) {
TextView listView = new TextView(this);
listView.setBackgroundColor(colorls[i]);
scrollView.addView(listView, new LinearLayout.LayoutParams(dm.widthPixels, dm.heightPixels));
}

setContentView(scrollView);
}

}

语句解释:
-  创建了三个TextView对象,尺寸与屏幕的宽高一致,可以把这两个类复制到项目中,直接运行。

View的滑动冲突

  本节介绍View体系中的一个深入话题:滑动冲突。只要在界面中存在内外两层同时可以滑动,这个时候就会产生滑动冲突。

  常见的滑动冲突场景有如下三种:

-  第一种,外部滑动方向和内部滑动方向不一致。
-  第二种,外部滑动方向和内部滑动方向一致。
-  第三种,上面两种情况的嵌套。

  在介绍如何处理这三类冲突之前,要先知道如下几个知识点:

-  ViewGroup重写onInterceptTouchEvent方法可以拦截事件:
   -  若在ACTION_DOWN时返回true,则子View不会接到任何事件,事件将由ViewGroup的onTouchEvent处理。
   -  若在ACTION_MOVE时返回true,则子View会接到ACTION_CANCEL事件,后续事件将交给ViewGroup处理。
   -  若在ACTION_UP时返回true,则子View只会接到ACTION_CANCEL事件,不会接到ACTION_UP事件。
   -  也就是说,只要事件被ViewGroup拦截,那么本事件序列结束之前,都不会在将事件传递给子View。
   -  同时,即便子View处理了事件,只要它没有禁用ViewGroup的拦截事件功能,那么ViewGroup的onInterceptTouchEvent仍会被调用。
-  子View可以通过调用它父View的requestDisallowInterceptTouchEvent方法来禁止其父View拦截事件。
   -  子View无法阻止父View的onInterceptTouchEvent方法接收ACTION_DOWN事件。
   -  子View通常会在接到ACTION_DOWN事件时,禁止其父View拦截事件。
   -  子View通常会在ACTION_MOVE事件中,解除对其父View的禁止,随后父View就能接到ACTION_MOVE事件了。
   -  子View在ACTION_UP事件中解除对其父View的禁止,则父View无法接到ACTION_UP事件。
   -  子View对父View的禁止,只在一个事件序列内有效,即子View在ACTION_DOWN时禁止父View,即便不将父View解禁,当本次事件序列结束,父再次接到ACTION_DOWN事件时就会清除掉禁用状态。


滑动方向不一致
  接着刚才的范例,我们把TextView换成ListView,就可以重现这种场景,即外部是左右滑动,内部是上下滑动。
  解决的思路是,当用户左右滑动时,让外部View处理事件,当上下滑动时,让内部View处理事件。
  重点在于,我们如何判断用户当前是左右滑,还是上下滑。 有好几种方式:

-  依据水平方向和垂直方向的距离差来判断
-  依据水平方向和垂直方向的速度差来判断
-  依据依据路径和水平方向所形成的夹角来判断

  接下来以“距离差”为例子,做示范,我们只需要在MyScrollView中重写xxx方法即可,其它代码不需要修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private int mLastInterceptX;
private int mLastInterceptY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int currX = (int) ev.getX();
int currY = (int) ev.getY();
switch (ev.getAction()) {
// 当手指按下的时候,MyScrollView不能拦截事件,否则子View将无法接到事件。

case MotionEvent.ACTION_MOVE:
// 当手指移动时,如果手指在x轴方向上移动的距离比y轴的距离长,则拦截事件。
// 注意,一旦此处拦截了事件,那么在本次事件序列结束之前,子View都接不到事件。
if (Math.abs(currX - mLastInterceptX) > Math.abs(currY - mLastInterceptY)) {
intercept = true;
}
break;
}
mLastInterceptX = currX;
mLastInterceptY = currY;
return intercept;
}

  然后是Activity的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MainActivity extends ActionBarActivity {

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

DisplayMetrics dm = getResources().getDisplayMetrics();
MyScrollView scrollView = new MyScrollView(this);
for (int i = 0; i < 3; i++) {
ListView listView = new ListView(this);
List<String> data = new ArrayList<String>();
for (int j = 0; j < 20; j++) {
data.add("List" + i + " - " + j);
}
ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_expandable_list_item_1, data);
listView.setAdapter(adapter);
scrollView.addView(listView, new LinearLayout.LayoutParams(dm.widthPixels, dm.heightPixels));
}

setContentView(scrollView);
}

}

语句解释:
-  程序运行后,发现已经解决了滑动冲突。


内部解决法
  上面是通过修改外部View的代码来解决滑动冲突的,接下来介绍一下如何通过修改内部View的代码来解决滑动冲突:

-  首先,然父ViewGroup不拦截action_DWON事件,拦截另外两个事件。
-  然后,由子View来决定事件处理。


  第一步,创建一个MyScrollView2类,所有代码与MyScrollView相同,但下面的代码不同:

1
2
3
4
5
6
7
8
9
10
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 按下事件不能拦截,否则子View将接不到事件。
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
return false;
} else {
// 除了按下事件之外的其它所有事件都会拦截。
return true;
}
}


  第二步,定义MyListView类:

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
public class MyListView extends ListView {
public MyListView(Context context) {
super(context);
}

private int mLastInterceptX;
private int mLastInterceptY;

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int currX = (int) ev.getX();
int currY = (int) ev.getY();
switch (ev.getAction()) {
// 当子View接到按下事件时,设置不允许父View拦截事件。
// 这意味着当前View一定能接到本次事件序列的后续事件。
case MotionEvent.ACTION_DOWN:
((ViewGroup)getParent()).requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
// 如果当前View发现用户手指水平方向移动的距离比垂直方向移动的大,则允许父View拦截事件。
// 又由于MyScrollView2的onInterceptTouchEvent方法会拦截任何“非按下”事件。
// 这意味着当前View将不会接到后续事件。
if (Math.abs(currX - mLastInterceptX) > Math.abs(currY - mLastInterceptY)) {
((ViewGroup)getParent()).requestDisallowInterceptTouchEvent(false);
}
break;
}
mLastInterceptX = currX;
mLastInterceptY = currY;

return super.dispatchTouchEvent(ev);
}
}


  第三步,Activity的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public class MainActivity extends ActionBarActivity {

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

DisplayMetrics dm = getResources().getDisplayMetrics();
MyScrollView2 scrollView = new MyScrollView2(this);
for (int i = 0; i < 3; i++) {
MyListView listView = new MyListView(this);
List<String> data = new ArrayList<String>();
for (int j = 0; j < 20; j++) {
data.add("MyList" + i + " - " + j);
}
ArrayAdapter<String> adapter
= new ArrayAdapter<String>(this, android.R.layout.simple_expandable_list_item_1, data);
listView.setAdapter(adapter);
scrollView.addView(listView, new LinearLayout.LayoutParams(dm.widthPixels, dm.heightPixels));
}

setContentView(scrollView);
}

}

语句解释:
-  从实现上来看,内部拦截法要复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。


  另外两种滑动冲突的处理方式也是类似,暂时就不举例了,以后有空的时候再补上。