[Android]触摸、滑动与嵌套滑动(三)ScrollView与滑动冲突

685 阅读9分钟

在「 [Android]触摸、滑动与嵌套滑动(一)和(二)」中,我们已经介绍了触摸的基本知识,而滑动则和触摸是有很大关联的,因为滑动本身就是由屏幕的多次采样得到的,而后Android的视图体系将会将我们的滑动事件逐层下发到目标View上,然后由对应的View进行消费。

我们知道,事件会随着View Hierarchy从Root“冒泡”到Leaf结点,至此所有的视图都有机会「看到」所有的ACTION_DOWN事件,所以,理论上它们有平等的机会去接受ACTION_DOWN和后续的事件。

但是这种预定的机制在一些时候并不是我们想要的。

此前的例子中,我们用了ScrollView中嵌套一个Button来展现一个ACTION_CANCEL,首先手指按下屏幕,Button会接受到ACTION_DOWN,此后在Button等待后续的ACTION_UP来生成一次点击事件(Click)。

但是实际情况下,我们按下屏幕的时候,并不一定就是想按下ScrollView中的Button,触发了ACTION_DOWN之后,我们可能这时候犹豫了,转而去上下滑动ScrollView。于是这时候ScrollView便会拦截某一个造成滑动的ACTION_MOVE,并向下发送一个ACTION_CANCEL。CANCEL的含义就在于此,事件被取消了,换句话来说是被其他的父视图剥夺了。

这个ACTION_CANCEL,我们在[Android]触摸、滑动与嵌套滑动(二)几种场景下的事件处理分析和调试 - 掘金 (juejin.cn)此前提到过,是由ACTION_MOVE对应的MotionEvent通过设置类型修改后下发的,它们对应的是同一个MotionEvent对象。

显然,即使在Button接收了ACTION_DOWN之后,ScrollView还在对后续的ACTION_MOVE不断地监听,达到某一个条件之后便斩断原先的事件传递链条,将最终的事件接受者修改为自己,消费接下来的事件。

但是这样就会导致原先属于Button的事件被剥夺了,ACTION_MOVE是给ScrollView,还是Button,这就造成了一次分歧,但是这不用我们去解决,因为ScrollView会观察所有的ACTION_MOVE,并在合适的时机去拦截ACTION_MOVE事件,以解决这一次的分歧。

但是如果我们不希望得到上述的处理方案,我们希望手指只要按下 + 抬起,便一定能响应Click事件,无论手指拖动到什么位置都会由Button响应事件,那么显然事件传递机制的处理方案便已经不符合我们的需求了,我们就要解决ScrollView和Button之间的一次冲突。

事件分发机制在产生了分歧之后,现有机制做出了错误的选择,与我们预期相反,这就变成了滑动的冲突。

1.ScrollView与TouchSlop

从上面的内容中,我们知道,冲突的来源主要就是:

父View不合时宜地拦截了子View的某一个事件导致子View的行为没有按照预期的行为进行下去。

其实这种冲突在开发中是比较常见的。

假如你使用ScrollView嵌套ScrollView去开发一个内外嵌套的可滑动视图,导致内层视图无法滑动。这便是因为内外两个ScrollView,默认情况下具有相同的滑动条件的判断,它们都会监听所有的ACTION_MOVE。因为事件冒泡模型的存在:

如果某一次的ACTION_MOVE达到了可滑动条件,一定是外层的ScrollView先拿到这次的ACTION_MOVE事件,并产生滑动偏移量,内层的ScrollVIew一定是无法滑动的。

要弄清楚其中的缘由,我们就要先找到最根本的点:什么时候ScrollView是可滑动的

从前面的几篇文章我们可以知道,一定是内部的ScrollView接收到ACTION_DOWN事件,然后后续的ACTION_MOVE事件被外部的ScrollView拦截了,并下发了一个ACTION_CANCEL。所以,我们可以从ACTION_CANCEL的下发着手,开始DEBUG,在上一篇文章中,我们已经提过了,就不再赘述了,但是可以很轻松地定位到ACTION_CANCEL的触发代码,并往前反推,很快可以锁定到OutScrollView的onInterceptTouchEvent中,它返回的是mIsBeingDragged的值,字面意思就是是否开始滚动。我们只需要找到置为true的情况即可。

public boolean onInterceptTouchEvent(MotionEvent ev) {

    final int action = ev.getAction();
    if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
        return true;
    }
    
    switch (action & MotionEvent.ACTION_MASK) {
        case MotionEvent.ACTION_MOVE: {
            final int y = (int) ev.getY(pointerIndex);
            final int yDiff = Math.abs(y - mLastMotionY);
            if (yDiff > mTouchSlop && ……) {
                mIsBeingDragged = true;
                mLastMotionY = y;
                // ……
                final ViewParent parent = getParent();
                if (parent != null) {
                    parent.requestDisallowInterceptTouchEvent(true);
                }
            }
            break;
        }        
  }

在省略掉一部分的代码和条件之后,我们可以比较清楚地看到,当yDiff > mTouchSlop的时候,就会将mIsBeingFragged置为true,这样一来在外层的ScrollView的onInterceptTouchEvent方法就会返回**true**

接下来就是返回到ViewGroup的dispatchTouchEvent方法中了,(因为是ViewGroup的dispatchTouchEvent中调用的onInterceptTouchEvent),然后就走了这段逻辑,含义就是去讲ACTION_MOVE转为ACTION_CANCEL下发给child的。

final boolean cancelChild = resetCancelNextUpFlag(target.child)
        || intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,
        target.child, target.pointerIdBits)) {
    handled = true;
}

对流程进行了一个大概的分析,我们可以提取出一个ScrollView可以滑动的基本条件,那就是

  • 某个ACTION_MOVE所对应的偏移量y的值 - 上一次ACTION_DOWN应的y的值的差值,形成的yDiff,即差值yDiff > TouchSlop,将视为一次可以滑动。

什么是TouchSlop呢?

当我们的手指在屏幕上滑动时,实际上是屏幕进行采样,产生了一系列的ACTION_MOVE事件,彼此ACTION_MOVE事件之间会有不同的坐标点(x,y),最终这些事件会以MotionEvent的形式下发到对应位置的View上,不同的采样之间,彼此的坐标点(x,y)也会有所不同,这两个不同的点就构成了一条线,这条线就是一次滑动,这也是为什么你在开发者选项打开滑动采样时,对于弯曲的滑动轨迹实际上是一段段拟合的直线:

除滑动之外,点击也是一个重要的事件,只不过点击分为Click和LongClick,一个是普通点击,另外一个是长按,二者的实现基本一致,但是长按的实现比较奇妙,我们后续有机会再讲,先来看看比较简单的点击: 预想的无非就是:ACTION_DOWN + ACTION_UP就构成了一次点击事件,即按下 + 抬起。

但是实际上是ACTION_DOWN + ACTION_MOVE x n个 + ACTION_UP,即按下加手指在屏幕上停留了0.5秒后抬起,这停留的0.5秒,实际上屏幕已经采样了数十个ACTION_MOVE了,ACTION_MOVE就意味着滑动,但是点击的这种情况下,生成的ACTION_MOVE显然并不是我们想要的,我们并不想滑动,或者说开发者并不知道此时用户是想要滑动还是点击。

于是TouchSlop应运而生,说白了就是太短的滑动不算点击。TouchSlop就是来划分这个大与小的中间值,如果两次之间的Diff > TouchSlop,就算是滑动;否则就不算。

一般来说TouchSlop的取值是8dp,具体在我的测试机上的像素值是:8 X 2.75 = 22pix,也就是说只有滑动量达到了22pix才视为一次滑动。

所以,就是在外层ScrollView上产生了大于22pix的滑动时,就会导致ACTION_CANCEL事件。ScrollView多个嵌套的情况下,永远是外层的ScrollView先拿到事件,所以永远是外层会先开始滑动。

2. 冲突的处理

那么如何解决这种冲突呢?

2.1 方法一 子View请求父View不要拦截事件

冲突的原因实际上我们之前已经提过了,就是外层的ScrollView会拦截掉造成滑动的那一次的ACTION_MOVE。 如果我们希望解决这种冲突,无非就是外层ScrollView不去拦截这一次的滑动,这里我们有方法可以直接调用,直接重写内层的ScrollView,并在接收到ACTION_DOWN的时候,调用:

parent.requestDisallowInterceptTouchEvent(true)

请求父View不要拦截自己接下来的事件即可。

一般来说,父View只要符合既有的时间传递机制的情况下,在子View调用了上述的方法之后,将不会再进行拦截,这样一来,嵌套在内层的ScrollView就可以滑动了。

这便是我们的处理方法之一:子View请求父View不要拦截

2.2 方法二 父View主动放弃事件

方法一是由子View触发的,方法二就是由父View主动触发的,父View可以利用一些方法判断子View在一些场景下是否可以滑动,如果可以则不拦截事件,不可以再去拦截,以纵向为例:

bool canScrollVertically(direction)

同时ScrollView也是通过它来判断自身是否能滑动的,如果不能滑动它将不会去拦截事件。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // ……

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

但是在ScrollView的onInterceptTouchEvent中,并没有调用child.canScrollVertically()来判断子VIew是否能够滑动,我们可以修改一下,自定义一个OutScrollView,重写onInterceptTouchEvent()

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    val direction = 1
    val linearLayoutScrollable = children.first().canScrollVertically(direction)//false
    val innerScrollViewScrollable =
        children.first().findViewById<ScrollView>(R.id.innerScrollView)
            .canScrollVertically(direction)//true
    if (linearLayoutScrollable || innerScrollViewScrollable) {
        return false
    }
    return super.onInterceptTouchEvent(ev)
}

这只是举个例子,你会发现这样只有InnerScrollView可以滑动,因为每次都去检测InnerScrollView了,即使你手指在其他区域滑动也会去检测InnerScrollView,这是不合理的,你可以根据ACTION_DOWN、MOVE事件去自定义它们的行为。而且当InnerScrollView滑到底时,innerScrollViewScrollable返回了false,将不会再走return false,外层的ScrollView又开始拦截其他事件了。

3. 总结

如果你按照2.中的方法,去自己实现了一下这种ScrollView嵌套滑动冲突的解决,你会发现虽然InnerScrollView中的内容,虽然是可以滑动了,但是你依然会觉得滑动起来有些异样,因为InnerScrollView滑动到底之后,如果手指不抬起,即使你再怎么样去向下滑动,也不会有反应。必须要手指先抬起,再重新按下、滑动,外层的OutScrollView才会滑动。

因为我们只是简单地处理了这其中的滑动冲突,让功能变得“可用”。但是在其他的常见的滑动控件中,我们的滑动可能是这样的:

即内部的控件滑动的溢出值会带动外部可滚动视图进行滑动,这便是「嵌套滑动」的范畴。当然这两种UI是不同的需求,只不过嵌套滑动是属于3.中方法一的延伸,受限于篇幅的问题,我们将在下一篇中介绍它。