Android 源码分析问题(三)—— 通过事件分发完美解决嵌套滑动冲突

前言

先上图。

请注意看开头部分

DampRefreshAndLoadMoreLayout

这里引用的是一张我之前写的一个组件的动图,很明显大家可以在我开头看到一个很流畅的下拉上拉的操作,没有任何阻碍,这就是我所说的完美解决嵌套滑动冲突方案。

接下来的话请注意:

阅读本文需要对Android的事件分发机制有一定的了解,如果不了解我建议先去了解一下Android的事件分发机制!

言归正传:

这里我打算通过一步步的模拟我们要遇到问题的情景,再一步步的解决这些问题,最终呈现出一个完美的解决方案。

1.解决嵌套滑动冲突第一步,拦截事件

情景1: 首先我们在自己写的容器中(可以滑动的容器都可以)添加一个 RecyclerView(只要是可以滑动的组件就可以,此处我以 RecyclerView为例),当遇到这种场景,我们就会发现无论我们怎么滑动 RecyclerView,父容器都不会滑动。

这时候了解过事件分发机制的朋友就很明显的可以发现,事件一直被RecyclerView消费了,父容器并没有消费到事件。

所以这里我已下拉为例,来提供第一个解决方案:

列表在顶部,且下拉时,容器将事件拦截:

 @Override
 public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()){
        case MotionEvent.ACTION_DOWN:
            mInitialDownY = (int)ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            int nowY = (int)ev.getY();
            int offsetY = mInitialDownY - nowY;
            mInitialDownY = nowY;
            if(!rvView.canScrollVertically(-1)) {
                if (offsetY < 0) {//判断子view是否滑动到顶部并且当前是下拉
                    return true;//是的就拦截事件
                }
            }
            break;
    }
    return false;
 }

如果你这样写了并运行后,很容易的就发现会出现一个问题:

情景2: 列表自身已经在滑动,滑动到最顶部时,继续下拉,事件没有被容器拦截,需要重新松手,按下再下拉,容器才会拦截事件。

很明显是因为列表还在持有事件。

那这时候的思路也很明确,在列表滚动时,到达最顶部或者底部,将事件还给父容器。

2.让子 View “交还”事件

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                mDispatchDownY = (int)ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                int nowY = (int)ev.getY();
                int offsetY = mDispatchDownY-nowY;
                mDispatchDownY = nowY;
                if((!rvView.canScrollVertically(-1)&&offsetY<0)||(!rvView.canScrollVertically(1)&&offsetY>0)){
                    //子 View 到达顶部或者底部,且滑动方向符合逻辑时,将事件还给父容器
                    //此处应该再添加相关逻辑避免父容器消费事件时频繁调用此方法
                     requestDisallowInterceptTouchEvent(false);
                }
                break;
        }
        return super.dispatchTouchEvent(ev);
    }

上面代码中出现了这个方法

requestDisallowInterceptTouchEvent(false);

这个方法用途是告诉容器可以拦截事件,如果参数是 true 的话就是不要拦截事件。

顺便来看下源码好了:

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {

        if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {
            //此处判断当前mGroupFlags是不是FLAG_DISALLOW_INTERCEPT,如果是则说明当前这个ViewGroup已经是FLAG_DISALLOW_INTERCEPT状态了,后面的代码没必要再执行了
            return;
        }

        if (disallowIntercept) {
            mGroupFlags |= FLAG_DISALLOW_INTERCEPT;//如果disallowIntercept为true,则将ViewGroup状态置为FLAG_DISALLOW_INTERCEPT
        } else {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;//反之就取消掉这个标记
        }

        // Pass it up to our parent
        if (mParent != null) {
            mParent.requestDisallowInterceptTouchEvent(disallowIntercept);//从这里看出来 这个方法是递归的
        }
    }

其实requestDisallowInterceptTouchEvent方法的源码很简单,看注释就知道它是怎么实现的了,有意思的是这个方法是递归的,所以一旦调用了requestDisallowInterceptTouchEvent(true)后,就会将当前ViewGroup以及所有包含了它的ViewGroup都置为FLAG_DISALLOW_INTERCEPT这个标记,这个标记看名字就知道是立了一个不拦截事件的flag

而我们通过调用requestDisallowInterceptTouchEvent(false)来告诉容器可以拦截事件,达到一个事件"交还"给父容器的效果。

至于为什么调用这个方法就能解决这个问题,其实和下一步的知识点有很大的关联,在后面一起做一个分析。

3.父容器持有事件时将事件转交给子View

这一步就是完美解决嵌套滑动冲突的最后一步,让我们来模拟一下发生这种情况的场景:

情景3: 当我们父容器再滑动时,滑动到某一个位置,或者说这个时候 按照我们写好的判断是否该拦截这个事件的方法 来判断出我们应该将事件交给子 View,让子 View 可以滑动,我们就发现了,此时子 View 并不会滑动,还是父容器在滑动。

这里依旧可以很明显的看出,父容器并没有把事件发给子 View。

所以我们追寻一下之前写的代码,发现在父容器里目前只在onInterceptTouchEvent方法中处理了相关逻辑,那秉着遇事不解看源码的原则,我们去看下onInterceptTouchEvent是在什么时候被执行的,不难发现,onInterceptTouchEvent是在ViewGroup的dispatchTouchEvent方法中被执行的,这里我贴出相关的源码:


    if (actionMasked == MotionEvent.ACTION_DOWN) {
        // Throw away all previous state when starting a new touch gesture.
        // The framework may have dropped the up or cancel event for the previous gesture
        // due to an app switch, ANR, or some other state change.
        cancelAndClearTouchTargets(ev);
        resetTouchState();//4
    }
            
    final boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN
        || mFirstTouchTarget != null) {//1
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;//2
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);//3
            ev.setAction(action); // restore action in case it was changed
        } else {
            intercepted = false;//5
        }
    } else {
        // There are no touch targets and this action is not an initial down
        // so this view group continues to intercept touches.
        intercepted = true;
    }

先来看注释1, 这里是个判断,判断条件就是当前事件是ACTION_DOWNmFirstTouchTarget不为null的情况,执行下一步,这里ACTION_DOWN就是第一个手指接触屏幕的时候产生的事件,mFirstTouchTarget则是可以接受事件的View。

再来看注释2, 这个地方是不是很熟悉,我们又看到了FLAG_DISALLOW_INTERCEPT,没错,这就是我们调用requestDisallowInterceptTouchEvent方法时相关的一个FLAG(其实这个状态只有调用requestDisallowInterceptTouchEvent(true)时会被设置),所以这里的逻辑就是 当前状态不为FLAG_DISALLOW_INTERCEPT时,disallowIntercept为 false,反之为 true。

插入注释4: 此处会关闭FLAG_DISALLOW_INTERCEPT状态,所以每次ACTION_DOWN时都会重置这个状态。

最后看注释3, 这里会执行我们需要的onInterceptTouchEvent方法,所以到现在我们得出结论,onInterceptTouchEvent方法,只有当前状态为ACTION_DOWNmFirstTouchTarget不为null的情况,当前ViewGroup状态不为FLAG_DISALLOW_INTERCEPT时,才会被调用,而且在情景3发生时,我们已经拦截过事件(不然事件不会由父容器消费),说明当前没有可以接收事件的子View。

为什么调用requestDisallowInterceptTouchEvent(false)可以“交还”事件,看到这里我们先把上面抛出的这个问题解决了,当子 View 在滑动时,像RecyclerView这些可滑动的组件,消费事件时内部一般都会调用getParent().requestDisallowInterceptTouchEvent(true)方法,将其所有的父容器的状态都标记为FLAG_DISALLOW_INTERCEPT,实现一个长期持有事件,只有触发ACTION_DOWN或者调用getParent().requestDisallowInterceptTouchEvent(false)时会重置这个状态,此时父容器mFirstTouchTarget不为空,所以不需要ACTION_DOWN也可以有机会执行onInterceptTouchEvent方法,容器调用requestDisallowInterceptTouchEvent(false) ,关闭状态FLAG_DISALLOW_INTERCEPT,此时注释2中disallowIntercept为 false,此时可以执行onInterceptTouchEvent方法,父容器经过判断,拦截事件。

解决了上面的问题我们再来得出一个结论: 父容器滑动时,不会执行 onInterceptTouchEvent方法把事件分发给子View。

解决方案也很直接: 模拟一次ACTION_DOWN事件,触发onInterceptTouchEvent方法,分发事件给子 View。

先上代码

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        switch (ev.getAction()){
            case MotionEvent.ACTION_DOWN:
                mDispatchDownY = (int)ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                mLastMoveMotionEvent = ev;//缓存最后的事件
                //计算偏移量
                int nowY = (int)ev.getY();
                int offsetY = mDispatchDownY-nowY;
                mDispatchDownY = nowY;
                
                if(...){//判断条件,在合适的时候模拟`ACTION_DOWN`事件
                    sendDownEvent(mLastMoveMotionEvent);
                }
                break;
        }

        return super.dispatchTouchEvent(ev);
    }
      
    /**
     * @param ev
     * 模拟down事件
     */
    private void sendDownEvent(MotionEvent ev){
        MotionEvent e = MotionEvent.obtain(ev.getDownTime(),ev.getEventTime(), MotionEvent.ACTION_DOWN,ev.getX(),ev.getY(),ev.getMetaState());
        super.dispatchTouchEvent(e);
    }

调用MotionEvent.obtain,模拟一个 down 事件,容器重新调用拦截方法,分发事件给子 View ,此时无阻碍的嵌套滑动实现。

总结

我说的完美解决嵌套滑动冲突就是上面这三步,但是实现这三步需要我们对Android事件分发机制要有一个清晰的了解,我们不应该局限于基本的事件分发教程中告诉你的这里返回 true 我们就拦截了事件,那里返回 false 我们就...

秉着遇事不解看源码的原则

而要更深的去了解Android事件分发,去解决遇到的相关问题,从上文的源码分析中,我们可以通过一小段代码分析出这么多的问题所在,所以解决问题还是要回归源码,从源码分析,找出问题,解决问题。

最后我放一张解决方案的流程图做一个总结:

Android 源码分析问题(三)—— 通过事件分发完美解决嵌套滑动冲突_第1张图片
完美解决嵌套滑动冲突.png
  1. 首先需要在onInterceptTouchEvent方法中判断是否拦截
  2. 当子 View消费事件时,判断不需要事件的时候调用requestDisallowInterceptTouchEvent(false)让父容器可以重新拦截事件
  3. 当父容器消费事件时,判断不需要事件的时候模拟ACTION_DOWN事件重新执行onInterceptTouchEvent方法,将事件发给子 View

你可能感兴趣的:(Android 源码分析问题(三)—— 通过事件分发完美解决嵌套滑动冲突)