在Android系统中,事件的传递和处理是让人很容易困惑的一个知识点,在处理复杂界面事件时,如果对这方面掌握不扎实,会遇到很多千奇百怪的问题。

本文可能是你读过的对事件传递系统最简单易懂的解读。


Android事件系统概述

任何UI界面系统的设计,都一定离不来事件传递,Android系统当然不例外。

要理解Android中的事件机制,需要牢记下面两个基本原则:

  • 事件传递,从外向内。
  • 事件消费,从内向外。

例子

事件传递

先触发外层ViewGroup的dispatchTouchEvent方法,该方法会在事件的整个处理流程结束后异步返回;接着触发外层ViewGroup的onInterceptTouchEvent方法,如果要拦截这个事件,不让它继续向下传递,就在这个方法里就要返回true;当事件继续向下传递时,内层View先触发dispatchTouchEvent,接着进行到事件消费环节。

事件消费

如果外层ViewGroup在onInterceptTouchEvent方法里返回true,那么事件仅由外层进行消费,即进入onTouchEvent方法里,消费完成后,进行dispatchTouchEvent结果回调,事件消费掉了则返回true,反之返回false

如果外层ViewGroup在onInterceptTouchEvent方法里返回false,事件继续向下传递,内层View在它的onTouchEvent里返回处理结果,再把结果交给内层View的dispatchTouchEvent方法返回。如果内层消费了事件(返回true),则外层不会触发onTouchEvent方法;如果内层没有消费事件(返回false),那么事件交由外层消费,会触发外层ViewGroup的onTouchEvent方法。

上例的补充说明

上例的case B,还有一种写法,是外层ViewGroup不对事件进行拦截,而是在内层View的onTouchEvent中返回false,这样事件依旧会交由外层ViewGroup的onTouchEvent进行消费。

同理,case D也有另外的写法,聪明的读者,你一定已经猜出来了。


几个关键方法

上文中已经看出,Android系统中对事件进行处理,主要是dispatchTouchEventonInterceptTouchEventonTouchEvent这三个方法,下面逐一进行分析。

dispatchTouchEvent

View处理事件时,最先进入的方法,直到事件处理完成后,才会返回true(在当前View或者内部View进行了消费)或者false(未进行消费)。

对于View,这个方法的返回值与onTouchEvent返回值相同。

对于ViewGroup,只要内部有任意一个View消费了事件,在ViewGroup的这个方法就会返回true

onInterceptTouchEvent

这个方法只存在于ViewGroup中,控制是否将当前时间拦截下来不向下传递,只给自己消费。

需要注意一旦ACTION_DOWN被拦截,后续的ACTION_MOVEACTION_UP根本不会走到这个方法里。

onTouchEvent

处理touch事件的地方,如果要处理的是click事件,不要写在onTouchEvent方法里,而是应该写在performClick中。

消费了事件则返回true,消费后的事件立即废弃,不会再交由其它View消费。

requestDisallowInterceptTouchEvent

上文讲到,外层的ViewParent可以通过onInterceptTouchEvent来拦截事件,不继续向下传递。上有政策下有对策,内层View同样可以拒绝外层ViewGroup的这种行为,这就是requestDisallowInterceptTouchEvent(boolean)方法所做的事。

这个方法位于ViewParent.java中,可以通过View.getParent()来获取某个View的Parent。该方法会应用到当前View的所有祖先ViewGroup,而不仅仅是父亲。所有接收到这个请求的Parent必须停止它们的Intercept行为,直到当前touch事件结束(收到up信号或者cancel信号)。


学以致用:解决在ScrollView中嵌套ListView的问题

有了这些的知识储备,我们来实操一下,解决一个很容易出现的case:在ScrollView中嵌套ListView时,ListView无法进行上下滑动,导致项目展示不完整。

首先,在ScrollView中嵌套ListView并不是好的设计,如果能够避免最好,若不能避免,就必须解决ListView不能滑动的问题。我们希望当手指落在ListView上进行滑动时,会控制ListView的条目,当滑至顶端或底端时,ListView无法再滑,则进行外层ScrollView的滑动。当手指落在ListView外部的ScrollView时,直接控制ScrollView进行滑动。

出现问题的原因

原因在于ScrollView在它的onInterceptTouchEvent里对ACTION_MOVE进行了拦截,Code donesn’t lie.

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
/*
* This method JUST determines whether we want to intercept the motion.
* If we return true, onMotionEvent will be called and we do the actual
* scrolling there.
*/

/*
* Shortcut the most recurring case: the user is in the dragging
* state and he is moving his finger. We want to intercept this
* motion.
*/
final int action = ev.getAction();
if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
return true;
}

/*
* Don't try to intercept touch if we can't scroll anyway.
*/
if (getScrollY() == 0 && !canScrollVertically(1)) {
return false;
}

switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE: {
/*
* mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
* whether the user has moved far enough from his original down touch.
*/

/*
* Locally do absolute value. mLastMotionY is set to the y value
* of the down event.
*/
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
// If we don't have a valid id, the touch down wasn't on content.
break;
}

final int pointerIndex = ev.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + activePointerId
+ " in onInterceptTouchEvent");
break;
}

final int y = (int) ev.getY(pointerIndex);
final int yDiff = Math.abs(y - mLastMotionY);
if (yDiff > mTouchSlop && (getNestedScrollAxes() & SCROLL_AXIS_VERTICAL) == 0) {
mIsBeingDragged = true;
mLastMotionY = y;
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(ev);
mNestedYOffset = 0;
if (mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
}

case MotionEvent.ACTION_DOWN: {
final int y = (int) ev.getY();
if (!inChild((int) ev.getX(), (int) y)) {
mIsBeingDragged = false;
recycleVelocityTracker();
break;
}

/*
* Remember location of down touch.
* ACTION_DOWN always refers to pointer index 0.
*/
mLastMotionY = y;
mActivePointerId = ev.getPointerId(0);

initOrResetVelocityTracker();
mVelocityTracker.addMovement(ev);
/*
* If being flinged and user touches the screen, initiate drag;
* otherwise don't. mScroller.isFinished should be false when
* being flinged.
*/
mIsBeingDragged = !mScroller.isFinished();
if (mIsBeingDragged && mScrollStrictSpan == null) {
mScrollStrictSpan = StrictMode.enterCriticalSpan("ScrollView-scroll");
}
startNestedScroll(SCROLL_AXIS_VERTICAL);
break;
}

case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
/* Release the drag */
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
recycleVelocityTracker();
if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange())) {
postInvalidateOnAnimation();
}
stopNestedScroll();
break;
case MotionEvent.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
}

/*
* The only time we want to intercept motion events is if we are in the
* drag mode.
*/
return mIsBeingDragged;
}

可以看到当(action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)条件为真时,这个方法直接返回true,拦截掉了ACTION_MOVE事件。解决办法也很简单,在内层View接收到ACTION_DOWN时,禁止掉外层的拦截;在内层View收到手指抬起来的ACTION_UP时,放掉禁制。我们在dispatchTouchEvent中写入这段逻辑,也可以把它写进内层View的onTouchListener中。

恢复ListView滑动

dispatchTouchEvent中的写法

1
2
3
4
5
6
7
8
9
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
// ......
}
}

onTouchListener中的写法

1
2
3
4
5
6
7
8
9
10
11
listView.setOnTouchListener(new View.OnTouchLister {
@Override
public boolean onTouch(View v, MotionEvent event) {
if(event.getAction() == MotionEvent.ACTION_UP){
scrollView.requestDisallowInterceptTouchEvent(false);
}else{
scrollView.requestDisallowInterceptTouchEvent(true);
}
return false;
}
});

这个时候ListView已经可以上下滑动了,但是当滑动到上/下尽头时,我们希望外层的ScrollView继续接管滑动。

外层ScrollView接管滑动

首先需要写两个方法判断ListView已经到了穷途末路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean scrollToBottom() {
int first = getFirstVisiblePosition();
int last = getLastVisiblePosition();
int visibleCoutn = getChildCount();
int count = getCount();
if ((first + visibleCoutn) == count) {
return true;
}
return false;
}

public boolean scrollToTop() {
int first = getFirstVisiblePosition();
int last = getLastVisiblePosition();
int visibleCoutn = getChildCount();
int count = getCount();

if (first == 0) {
return true;
}
return false;
}

然后在dispatchTouchEvent方法里判断,到了尽头后,把ACTION_MOVE事件交给外层ScrollView处理。这里包含了前文中“禁止外层ViewGroup拦截”的逻辑。

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
float downY, y, mTouchSlop;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
downY = ev.getRawY();
y = downY;
getParent().requestDisallowInterceptTouchEvent(true);
break;

case MotionEvent.ACTION_MOVE:
y = ev.getRawY();
if (scrollToTop()) {
if (y - downY > mTouchSlop) {
/**
* Point 1 : 如果滑动到顶部,并且手指还想向下滑动,则事件交还给父控件,要求父控件可以拦截事件
*/
getParent().requestDisallowInterceptTouchEvent(false);
return false;
}
}

if (scrollToBottom()) {
if (y - downY < -mTouchSlop) {
/**
* Point 3 : 如果滑动到底部,并且手指还想向上滑动,则事件交还给父控件,要求父控件可以拦截事件
*/
getParent().requestDisallowInterceptTouchEvent(false);
return false;
}
}

break;

case MotionEvent.ACTION_UP:
break;

default:
break;
}
return super.dispatchTouchEvent(ev);
}

上述写法基本实现了拉到ListView尽头时,将滑动时间交给外部ScrollView处理的功能。还存在的问题是在边界判断时不够精确,会发生第一个/最后一个item只展示一个边界,就触发解除禁止的效果,待完善。

希望这篇文章让你不再为Android中的事件传递机制而困惑。


参考资料


====Ending====