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

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

完美解决嵌套滑动冲突.png
  1. 首先需要在onInterceptTouchEvent方法中判断是否拦截
  2. 当子 View消费事件时,判断不需要事件的时候调用requestDisallowInterceptTouchEvent(false)让父容器可以重新拦截事件
  3. 当父容器消费事件时,判断不需要事件的时候模拟ACTION_DOWN事件重新执行onInterceptTouchEvent方法,将事件发给子 View
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容