美文网首页Android自定义View
Android 通过事件分发完美解决嵌套滑动冲突

Android 通过事件分发完美解决嵌套滑动冲突

作者: JzyCc | 来源:发表于2018-09-29 11:37 被阅读72次

    前言

    先上图。

    请注意看开头部分

    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事件分发,去解决遇到的相关问题,从上文的源码分析中,我们可以通过一小段代码分析出这么多的问题所在,所以解决问题还是要回归源码,从源码分析,找出问题,解决问题。

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

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

    如果对您有帮助的话可以喜欢收藏一下!有问题和建议的话欢迎在评论处评论!

    相关文章

      网友评论

        本文标题:Android 通过事件分发完美解决嵌套滑动冲突

        本文链接:https://www.haomeiwen.com/subject/eywpoftx.html