嵌套滑动与冲突解决

       在界面中,只要内外两层同时可以滑动,这个时候就会产生滑动冲突。

嵌套滑动的场景

       常见的嵌套滑动(滑动冲突)场景可以简单分为如下三种:

  • 外部滑动方向和内部滑动方向不一致;
  • 外部滑动方向和内部滑动方向一致;
  • 上面两种情况的嵌套。

常见的滑动冲突场景

       场景1主要是将 ViewPager 和 Fragment 配合使用所组成的页面滑动效果,主流应用几乎都会使用这个效果。在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个 ListView。本来这种情况下是有滑动冲突的,但是 ViewPager 内部处理了这种滑动冲突,因此采用 ViewPager 时我们无须关注这个问题,如果我们采用的不是 ViewPager 而是 ScrollView 等,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只能有一层能够滑动。除了这种典型情况外,还存在其它情况,比如外部上下滑动、内部左右滑动等,但是它们属于同一类滑动冲突。

       场景2的情况稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。因为当手指开始滑动的时候,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动得很卡顿。在实际的开发中,这种场景主要是指内外两层同时能上下滑动或者内外两层同时能左右滑动。例如两个 ScrollView 同向嵌套时,⽗ View 会彻底卡住⼦ View 导致子 ScrollView 无法滑动,这是因为在事件序列中二者抢夺滑动条件⼀致,但 ⽗ View 的 onInterceptTouchEvent() 早于⼦ View 的 dispatchTouchEvent()。

       场景3是场景1和场景2两种情况的嵌套,因此场景3的滑动冲突看起来就更加复杂了。比如外部有一个 SlidingMenu 效果,然后内部有一个 ViewPager,ViewPager 的每一个页面中又是一个 ListView。虽然说场景3的滑动冲突看起来更复杂,但是它是几个单一的滑动冲突的叠加,因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可,而具体的处理方法其实是和场景1、场景2相同的。

滑动冲突的处理规则

       一般来说,不管滑动冲突多么复杂,它都有既定的规则,根据这些规则我们就可以选择合适的方法去处理。

       对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的 View 拦截点击事件,当用户上下滑动时,需要让内部 View 拦截点击事件。这个时候我们就可以根据它们的特征来解决滑动冲突,具体来说是:根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件,如下图所示:

滑动过程示意图

       根据滑动过程中两个点之间的坐标就可以得出到底是水平滑动还是竖直滑动。如何根据坐标来得到滑动的方向呢?这个很简单,有很多可以参考,比如可以依据滑动路径和水平方向所形成的夹角,也可以依据水平方向和竖直方向上的距离差来判断,某些特殊时候还可以依据水平和竖直方向的速度差来做判断。这里我们可以通过水平和竖直方向的距离差来判断,比如竖直方向滑动的距离大就判断为竖直滑动,否则判断为水平滑动。根据这个规则就可以进行下一步的解决方法制定了。

       对于场景2来说,它无法根据滑动的角度、距离差以及速度差来做判断,但是这个时候一般都能在业务上找到突破点,比如业务上有规定:当处于某种状态时需要外部 View 响应用户的滑动,而处于另外一种状态时则需要内部 View 来响应 View 的滑动,根据这种业务上的需求我们也能得出相应的处理规则,有了处理规则同样可以进行下一步处理。这本质上是策略问题,像 NestedScrollView 嵌套时,帮我们解决的滑动冲突规则:⼦ View 能滑动的时候,滑⼦ View;滑不动的时候,滑⽗ View。

       对于场景3来说,它的滑动规则就更复杂了,和场景2一样,它也无法直接根据滑动的角度、距离差以及速度差来做判断,同样还是只能从业务上找到突破点,具体方法和场景2一样,都是从业务的需求上得出相应的处理规则。

滑动冲突的解决方式

       其实⼤多数场景,SDK 就解决了,像将 ScrollView 嵌套换成 NestedScrollView;如果用 ViewPager 去实现场景1中的效果,我们不需要手动处理滑动冲突,因为 ViewPager 已经帮我们做了。

外部拦截法

       所谓外部拦截法是指点击事件都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。外部拦截法需要重写父容器的 onInterceptTouchEvent 方法,在内部做相应的拦截即可,这种方法的伪代码如下所示:

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
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;
break;
case MotionEvent.ACTION_MOVE:
if("父容器需要当前点击事件"){
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
mLastXIntercept = x;
mLastYIntercept = x;
return intercepted;
}

       上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其它均不需做修改。在 onInterceptTouchEvent 方法中,首先是 ACTION_DOWN 这个事件,父容器必须返回 false,即先不拦截 ACTION_DOWN 事件,这是因为一旦父容器拦截了 ACTION_DOWN,那么后续的 ACTION_MOVE 和 ACTION_UP 事件都会直接交由父容器处理,这个时候事件没法再传递给子元素了;其次是 ACTION_MOVE 事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回 true,否则返回 false;最后是 ACTION_UP 事件,这里必须要返回 false,因为 ACTION_UP 事件本身没有太多意义。

内部拦截法

       内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素,如果子元素需要此事件就直接消耗掉,否则就交由父容器进行处理,这种方法和 Android 中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent 方法才能正常工作,使用起来较外部拦截法稍显复杂。它的伪代码如下,我们需要重写子元素的 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
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = x - mLastY;
if("父容器需要此类点击事件"){
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}

       上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其它不需要做改动。除了子元素需要做处理之外,父元素默认也要拦截除 ACTION_DOWN 之外的其它事件,这样当子元素调用 getParent().requestDisallowInterceptTouchEvent(true) 方法时,父元素才能继续拦截所需要的事件。

       为什么父容器不能拦截 ACTION_DOWN 事件呢?那是因为 ACTION_DOWN 事件并不受 FLAG_DISALLOW_INTERCEPT 这个标记位的控制,所以一旦父容器拦截 ACTION_DOWN 事件,那么所有的事件都无法传递到子元素中去,这样内部拦截法就无法起作用了,父元素所做的修改如下所示:

1
2
3
4
5
6
7
8
9
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if(action == MotionEvent.ACTION_DOWN){
return false;
}else {
return true;
}
}

实现 NestedScrollingChild2 接⼝来实现⾃定义的嵌套滑动逻辑

       有 NestedScrollingChild1 和 NestedScrollingChild2,NestedScrollingChild2 对快速滑动(fling)的支持更好了,用 NestedScrollingChild2 即可。

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
@Override
public boolean startNestedScroll(int axes, int type) {
return false;
}

@Override
public void stopNestedScroll(int type) {

}

//是否有一个滑动的父控件
@Override
public boolean hasNestedScrollingParent(int type) {
return false;
}

@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
return false;
}

@Override
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
return false;
}

代码示例

外部拦截法

       我们来实现一个类似于 ViewPager 中嵌套 ListView 的效果,为了制造滑动冲突,我们写一个类似 ViewPager 的控件即可,名字叫 HorizontalScrollViewEx。

       为了实现 ViewPager 的效果,我们定义了一个类似于水平的 LinearLayout 的布局,只不过它可以水平滑动,初始化时我们在它的内部添加若干个 ListView,这样一来,由于它内部的 ListView 可以竖直滑动,而它本身又可以水平滑动,就造成了一个典型的滑动冲突场景,并且这种冲突属于类型1的冲突。根据滑动策略,我们可以选择水平和竖直的滑动距离差来解决滑动冲突。

       DemoActivity 代码很简单,就是创建了3个 ListView 并且把 ListView 加入到自定义的 HorizontalScrollViewEx 中,这里 HorizontalScrollViewEx 是父容器,而 ListView 则是子元素。

       首先采用外部拦截法来解决这个问题,按照之前的分析,我们只需要修改父容器需要拦截的条件即可。对于本例来说,父容器的拦截条件就是滑动过程中水平距离差比竖直距离差大,在这种情况下,父容器就拦截当前点击事件,根据这一条件进行相应修改,修改后的 HorizontalScrollViewEx 的 onInterceptTouchEvent 方法如下所示:

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
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
intercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int deltax = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if (Math.abs(deltax) > Math.abs(deltaY)) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
default:
break;
}
Log.d(TAG, "intercepted=" + intercepted);
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}

       从上面的代码来看,它和外部拦截法的伪代码的差别很小,只是把父容器的拦截条件换成了具体的逻辑。在滑动过程中,当水平方向的距离大时就判断为水平滑动,为了能够水平滑动所以让父容器拦截事件;而竖直距离大时父容器就不拦截事件,于是事件就传递给了 ListView,所以 ListView 也能上下滑动,这就解决了滑动冲突了。至于 mScroller.abortAnimation() 这句代码主要是为了优化滑动体验而加入的。

       考虑一种情况,如果此时用户正在水平滑动,但是水平滑动停止之前如果用户再迅速进行竖直滑动,就会导致界面在水平方向无法滑动到终点从而处于一种中间状态。为了避免这种不好的体验,当水平方向正在滑动时,下一个序列的点击事件仍然交给父容器处理,这样水平方向就不会停留在中间状态了。具体的滑动冲突相关的代码可查看 HorizontalScrollViewEx

内部拦截法

       如果采用内部拦截法也是可以的,按照前面对内部拦截法的分析,我们只需要修改 ListView 的 dispatchTouchEvent 方法中的父容器的拦截逻辑,同时让父容器拦截 ACTION_MOVE 和 ACTION_UP 事件即可。为了重写 ListView 的 dispatchTouchEvent 方法,我们必须自定义一个 ListView,称为 ListViewEx,然后对内部拦截法的模板代码进行修改,请查看:ListViewEx

       除了上面对 ListView 所做的修改,我们还需要修改 HorizontalScrollViewEx 的 onInterceptTouchEvent 方法,修改后的类暂且叫 HorizontalScrollViewEx2,其 onInterceptTouchEvent 方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN){
mLastX = x;
mLastY = y;
if(!mScroller.isFinished()){
mScroller.abortAnimation();
return true;
}
return false;
}else {
return true;
}
}

       其中 mScroller.abortAnimation() 这一句不是必须的,在当前这种情形下主要是为了优化滑动体验。

实现 NestedScrollingChild2 接⼝

       详见 NestedScalableImageView

参考资料:
《Android 开发艺术探索》任玉刚 第3章 3.5 View的滑动冲突
腾讯课堂 HenCoder

Fork me on GitHub