View 滑动冲突解决方式以及原理

目录
  1. 1. 一. 滑动冲突场景以及产生原因
  2. 2. 二. 滑动冲突的解决方式
    1. 2.1. 2.1 ViewPager 滑动冲突
    2. 2.2. 2.2 外部拦截法
      1. 2.2.1. 2.2.1 原理
      2. 2.2.2. 2.2.2 解决方式
      3. 2.2.3. 2.2.3 总结
      4. 2.2.4. 2.2.4 通用模板
    3. 2.3. 2.3 内部拦截法
      1. 2.3.1. 2.3.1 冲突场景
      2. 2.3.2. 2.3.2 原理
      3. 2.3.3. 2.3.3 具体实现
      4. 2.3.4. 2.3.4 总结

一. 滑动冲突场景以及产生原因

产生滑动冲突的场景主要有两种:

  • 父ViewGroup和子View的滑动方向一致
  • 父ViewGroup和子View的滑动方向不一致

那为什么会产生滑动冲突呢,例如在父ViewGroup和子View的滑动方向一致的情况,我需要让两者都可以滑动。在上篇博客中我们分析了事件分发机制,其中提到ViewGroup的onInterceptTouchEvent方法默认情况下是返回false,也就是ViewGroup默认情况下是不会拦截事件的。当ViewGroup接收到事件时,由于不拦截事件,会去寻找能够处理事件的子View。此时,一旦子View处理了DOWN事件,默认情况下接下来同一事件序列的其他事件都交由子View处理,此时可以看到的效果是子View可以滑动,但是父ViewGroup始终滑动不了,此时滑动冲突就出现了。

二. 滑动冲突的解决方式

滑动冲突主要有两种解决方式:外部拦截法和内部拦截法

2.1 ViewPager 滑动冲突

例如我们使用ViewPager时,往往会结合Fragment,然后Fragment内部为一个ListView。这里ViewPager已经为我们解决了滑动冲突,因此在使用时并不会冲突。试想下,若ViewPager未解决滑动冲突,默认情况下ViewPager的onInterceptTouchEvent方法返回false,由于ListView可以滚动,代表ListView可以处理事件,所以所有事件都被ListView处理了,因此我们看到的效果会是ListView可以在竖直方向上滚动,但是ViewPager在水平方向上无法滑动。

可以重写ViewPager,让ViewPager的onInterceptTouchEvent方法返回默认状态下的false,ViewPager内部是多个ListView。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyViewPager extends ViewPager {
public MyViewPager(@NonNull Context context) {
super(context);
}

public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return false;
}

}

运行效果如图

所以ViewPager是如何解决这样的滑动冲突的呢,由此引出外部拦截法。

2.2 外部拦截法

2.2.1 原理

所谓外部拦截法,就是当事件传递到父容器时,通过父容器去判断自己是否需要此事件,若需要则拦截事件,不需要则不拦截事件,将事件传递给子View。 上述MyViewPager和ListView显然产生了滑动冲突,我们来分析下。我们要的效果是在水平方向上滑动时ViewPager可以水平滚动,在竖直方向上滑动时,ListView可以滚动但ViewPager不动,因此我们需要为ViewGroup指定事件处理的条件,于是就有了下面的伪代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
if (ViewPager需要此事件) {
return true;
}
break;
default:
break;
}
return false;
}

现在我们来分析下为什么这段代码可以解决滑动冲突。

这边首先要注意一点,外部拦截时在重写ViewGroup的onInterceptTouchEvent方法时,ViewGroup不能拦截DOWN事件和UP事件。因为一旦ViewGroup拦截了DOWN事件,也就是和mFirstTouchTarget始终为空,同一事件序列中的其他事件都不会再往下传递;若ViewGroup拦截了UP事件,则子View就不会触发单击事件,因为子View的单击事件是在UP事件时被触发的。

  • 当ViewPager接收到DOWN事件,ViewPager默认不拦截DOWN事件,DOWN事件交由ListView处理,由于ListView可以滚动,即可以消费事件,则ViewPager的mFirstTouchTarget会被赋值,即找到处理事件的子View。然后ViewPager接收到MOVE事件,
  • 若此事件是ViewPager不需要,则同样会将事件交由ListView去处理,然后ListView处理事件;
  • 若此事件ViewGroup需要,因为DOWN事件被ListView处理,mFirstTouchEventTarget会被赋值,也就会调用onInterceptedTouchEvent,此时由于ViewPager对此事件感兴趣,则onInterceptedTouchEvent方法会返回true,表示ViewPager会拦截事件,此时当前的MOVE事件会消失,变为CANCEL事件,往下传递或者自己处理,同时mFirstTouchTarget被重置为null。
  • 当MOVE事件再次来到时,由于mFristTouchTarget为null,所以接下来的事件都交给了ViewPager。

2.2.2 解决方式

这边ViewPager处理事件的条件可以有多种方法,例如水平方向和竖直方向上的滑动速度、水平方向和竖直方向的滑动距离等。这边根据滑动距离判断,当水平方向的滑动距离大于竖直方向的滑动距离,则ViewPager处理事件,反之则将事件传递给ListView。

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
public class MyViewPager extends ViewPager {
private int mLastX;
private int mLastY;

public MyViewPager(@NonNull Context context) {
super(context);
}

public MyViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
//一些ViewPager拖拽的标志位要设置,必调super,否则看不到效果
super.onInterceptTouchEvent(ev);

boolean isIntercepted = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
if (needEvent(ev)) {
isIntercepted = true;
}
break;
default:
}
mLastX = (int) ev.getX();
mLastY = (int) ev.getY();

LogUtils.d(" lastX = " + mLastX + " lastY = " + mLastY);
return isIntercepted;
}

private boolean needEvent(MotionEvent ev) {
//水平滚动距离大于垂直滚动距离则将事件交由ViewPager处理
return Math.abs(ev.getX() - mLastX) > Math.abs(ev.getY() - mLastY);
}

}

运行效果:

2.2.3 总结

  • 外部拦截法主要是父容器去控制事件的拦截,若事件是父容器需要的,则进行拦截,不需要的则向下传递。
  • 父容器不能拦截DOWN事件或者UP事件。

2.2.4 通用模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean isIntercept = false;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//DOWN事件不能拦截,否则事件将无法分发到子View
isIntercept = false;
break;
case MotionEvent.ACTION_MOVE:
//根据条件判断是否拦截事件
isIntercept = needThisEvent();
break;
case MotionEvent.ACTION_UP:
//一旦父容器拦截了UP事件,子View将无法触发点击事件
isIntercept = false;
break;
default:
break;
}
return isIntercept;
}

2.3 内部拦截法

2.3.1 冲突场景

下面讲一种稍微复杂一点的同向滑动冲突。ScrollView内部的内部的LinearLayout存在三个子View,从上到下分别为ImageView、ListView以及TextView。

同向滑动冲突场景

先上下效果图:

可以看到现在需要的效果是触摸ListView外部的区域,ScrollView的滑动不受限制。当触摸ListView区域时,存在多种情况。当ListView滚动到顶部时(ListView处于初始状态),此时若手指往下滑动,则ScrollView往下滑动;当ListView滚动到底部时,若此时手指往上滑动,则ScrollView往上滑动,其余情况下ListView滚动。

2.3.2 原理

内部拦截法: ViewGroup默认情况下不拦截事件,由子View去控制事件的处理,若子View需要此事件,则自己处理,否则交由父容器处理。

使用内部拦截需要同时重写父ViewGroup的onInterceptTouchEvent和ViewGroup中需要解决冲突的子View的dispatchTouchEvent方法,和上面一样,先上伪代码。

子View伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//禁止父容器拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if (当期View不需要此事件) {
// 允许父容器拦截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}

ViewGroup 伪代码

1
2
3
4
5
6
7
8
9
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
return false;
default:
return true;
}
}

这边我们结合ScrollView和ListView这个具体实例和流程图进行分析。

首先父容器ScrollView不能拦截DOWN事件,必须将DOWN事件分发至子View,这边子View是 ListView,因为父容器一旦拦截DOWN事件,同一事件序列中的其他事件都不会传递到子View,这点在事件分发源码分析时已经分析了,这里不再赘述。

由于内部拦截是将事件交由子View,由子View去控制事件的处理,所以事件在一开始不能被父ViewGroup直接拦截,由于DOWN事件被子View处理,此时mFristTonchTarget不为null,在默认情况下会去调用onInterceptedTouchEvent,若针对该事件该方法返回true,则事件就会被父容器拦截了,事件显然不会传递到子View,但是我们需要将事件传递到子View,让子View去控制事件的处理。那我们要怎么将事件传递到子View呢?从源码可以看到在调用onInterceptedTouchEvent方法前还有一个判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
//是否禁止拦截事件,默认为false
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 {
intercepted = true;
}

从源代码可以看到,会根据disallowIntercept的值判断是否要调用onInterceptTouchEvent这个方法,disallowIntercept默认为false。此时若可以将disallowIntercept的值变为true,就可以绕过onIntercepted方法,将事件传递到子View了,也就是我们需要在MOVE事件到来之前给mGroupFlags设置FLAG_DISALLOW_INTERCEPT标志位,设置好后,若MOVE事件到来,disallowIntercept的值就会变为true,就会绕过onInterceptedTouchEvent方法的执行,将事件传递到子View了,那如何在MOVE事件到来之前给ViewGroup设置这个标志位呢?我们可以在ViewGroup中看到这个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
return;
}

if (disallowIntercept) {
mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
} else {
mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}

// Pass it up to our parent
if (mParent != null) {
mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
}
}

可以看到,若在调用requestDisallowInterceptTouchEvent方法时,参数为true,则mGroupFlags设置了FLAG_DISALLOW_INTERCEPT标志位,也就是disallowIntercept的值就会变为true。至于调用时机,我们只需要在子View接收到DOWN事件时调用该方法即可,此后父ViewGroup会直接将事件传递给处理DOWN事件的子View。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//禁止父容器拦截事件
getParent().requestDisallowInterceptTouchEvent(true);
break;
...
}
...
}
}

若接下来的事件是子View感兴趣的,则直接处理掉,如果子View对事件不感兴趣,则将事件交还给父View,让它去处理。那么问题又来了,如何将子View不需要的事件重新交还给父View处理?此时可能有人会说,在事件分发中,子View处理不了的事件,不是自动会交给父ViewGroup处理吗?我们说的子View处理不了的事件会传递给父ViewGroup处理,这个是针对默认的DOWN事件分发流程,但是在这不是DOWN事件且这里存在人工干预的情况,真的会是这样吗,我们来看看源码。

先明确下当前的情景,子View处理了DOWN事件和部分MOVE事件,此时父ViewGroup的mFirstTouchEvent肯定是不为null的。接下来的MOVE事件子View不需要,也就是子View不做处理,那么子View的dispatchTouchEvent方法会返回false。

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
public boolean dispatchTouchEvent(MotionEvent ev) {

...

if (mFirstTouchTarget == null) {
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;

//子View 不处理事件, 子View的dispatchTouchEvent返回false,dispatchTransformedTouchEvent为false
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
if (cancelChild) {
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}

if (canceled
|| actionMasked == MotionEvent.ACTION_UP
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
resetTouchState();
} else if (split && actionMasked == MotionEvent.ACTION_POINTER_UP) {
final int actionIndex = ev.getActionIndex();
final int idBitsToRemove = 1 << ev.getPointerId(actionIndex);
removePointersFromTouchTargets(idBitsToRemove);
}

if (!handled && mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(ev, 1);
}

//直接返回false
return handled;
}

从源代码可以看到,在这个情景下,ViewGroup的dispatchTouchEvent方法会直接返回false,不处理当前子View不感兴趣的MOVE事件,父ViewGroup的父容器也是这样直接返回false,直到传递给Activity,事件被Activity处理或者消失。并且当再一个MOVE事件来临时,MOVE还是会传递到子View,但是子View对当前MOVE事件不感兴趣,也就是说之后的所有MOVE事件都不会被父ViewGroup处理,这样明显是存在问题的。所以子View在对事件不感兴趣时,要如何事件处理权交给父ViewGroup?我们在子View 通过调用ViewGroup的requestDisallowInterceptTouchEvent方法,禁止父ViewGroup拦截事件,同样也可以在子View对事件不感兴趣时,调用ViewGroup的requestDisallowInterceptTouchEvent方法,允许父容器去拦截事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
if (当期View不需要此事件) {
// 允许父容器拦截事件
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}

对子View来说,对事件处理的控制逻辑已经完成了,但是对于父ViewGroup来说并没有,必须要重写ViewGroup的onInterceptedTouchEvent方法,让MOVE和UP事件返回true,表示拦截子View不感兴趣的事件,这边父ViewGroup拦截MOVE事件是可以理解的,但是为什么要拦截UP事件呢,因为父ViewGroup只有拦截了UP事件才可以接收单击事件。

2.3.3 具体实现

上述分析了原理,现在来真正解决一下ScrollView和ListView的滑动冲突。其实内部拦截的模板已经在伪代码中体现了。只要实现子View 对事件处理的判断即可。我们需要监听ListView滚动到顶部和底部的状态,当ListView滚动到顶部时且手指触摸方向向下或者ListView滚动到底部且手机触摸方向向上,则将事件交由ScrollView处理。

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
public class MyListView extends ListView implements AbsListView.OnScrollListener {

private boolean isScrollToTop;
private boolean isScrollToBottom;

private int mLastX;
private int mLastY;

public MyListView(Context context) {
this(context, null);
}

public MyListView(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}

public MyListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

private void init() {
setOnScrollListener(this);
}

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
LogUtils.d("" + Constants.getActionName(ev.getAction()));
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
mLastX = (int) ev.getX();
mLastY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (superDispatchMoveEvent(ev)) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
LogUtils.d("ACTION_UP");
break;
default:
break;
}
return super.dispatchTouchEvent(ev);
}

/**
* 将事件交由父容器处理
*
* @param ev
* @return
*/
private boolean superDispatchMoveEvent(MotionEvent ev) {
//下滑
boolean canScrollBottom = isScrollToTop && (ev.getY() - mLastY) > 0;
boolean canScrollTop = isScrollToBottom && (ev.getY() - mLastY) < 0;

return canScrollBottom || canScrollTop;
}

@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {

}

@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
isScrollToBottom = false;
isScrollToTop = false;

if (firstVisibleItem == 0) {
android.view.View firstVisibleItemView = getChildAt(0);
if (firstVisibleItemView != null && firstVisibleItemView.getTop() == 0) {
LogUtils.d("##### 滚动到顶部 ######");
isScrollToTop = true;
}
}

if ((firstVisibleItem + visibleItemCount) == totalItemCount) {
View lastVisibleItemView = getChildAt(getChildCount() - 1);
if (lastVisibleItemView != null && lastVisibleItemView.getBottom() == getHeight()) {
LogUtils.d("##### 滚动到底部 ######");
isScrollToBottom = true;
}
}
}

}

至于ScrollView,默认在拖拽状态下会拦截MOVE事件,默认不拦截UP事件,若需要拦截UP事件,可重写ScrollView的onInterceptTouchEvent方法,但不是必须拦截UP事件,若父ViewGroup不需要触发单击事件,则可以不拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyScrollView extends ScrollView {
public MyScrollView(Context context) {
super(context);
}

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

public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = super.onInterceptTouchEvent(ev);
if (ev.getAction() == MotionEvent.ACTION_UP) {
intercepted = true;
}
return intercepted;
}
}

2.3.4 总结

  • 内部拦截法是将事件控制权交给子View,若子View需要事件,则对事件进行处理,不需要则将事件传递给父ViewGroup,让父ViewGroup处理。
  • 子View通过调用父ViewGroup的requestDisallowInterceptTouchEvent来干预父ViewGroup对事件的拦截状况
  • 父ViewGroup不能拦截DOWN事件,至于MOVE或者UP事件的拦截状态要根据具体的情景

好了,到这里两种解决滑动冲突的方式就介绍完了,但要注意的是解决ViewPager与ListView滑动冲突并不是只能用外部拦截,同样可以使用内部拦截实现,第二个情景也是一样。解决方式并不是绝对的,我们要做的是选择最方便实现的方案。