Android-事件分发-开源库实战分析

334 阅读9分钟
哪有那么多为什么,哪有那么多的抱怨,因为不管你多努力,你总是不够努力。

自我的反思:

   我们学习了事件分发的原理,经常用来解决一些滑动冲突的问题,但是我能不能实现一个下拉刷新的操作呢?以前ListView横行的时候,我们经常在ListView头部加一个下拉刷新的头部View,这个方法难道不是很不优雅吗?更主要的是,
现在流行的RecyclerView也没有addHeader这个方法呀?列表就是缓存的,就是展示数据的,下拉刷新的header本来就不属于列表数据渲染,是你自己魔改,增加的,对吧。那我们有么有一个优雅的方式实现下拉刷新,实现无侵入支持各种
列表的下拉刷新呢?

问题

我们回忆一下我们的事件分发相关的只是,先抛出几个问题:

  • 怎么实现下拉刷新的效果呢?定义一个header放在RecyclerView里面?还是定义一个header“拼接”在RecyclerView的头部呢?

    • 很明显,之前反思有说过,滑动列表就是展示列表内容的,而且我们还要打造无侵入式的下拉刷新,我们一定是创建一个header,然后让header和RecyclerView“拼接”起来。那么这部分工作,我们可以包裹在一个ViewGroup,刚开始让这个ViewGroup往上滑动header的高度,实现隐藏的效果,达到下拉刷新的阈值的时候,跟随手势下拉。
  • 下拉,很显然是下拉到列表内容区全部漏出来之后,才开始显示出头部,出现loading效果,那么这个手势会有滑动冲突吗?

    • 展示列表是上下滑动的,达到下拉刷新的阈值的时候,再往下拉,这个时候,RecyclerView全部展示了,要展示header了。RecyclerView是“滑不动了”,但是还是要列表整齐下拉,直观的感受是,RecyclerView“不能动了”,它要随着“凭借”头部,整体一起往下移动,松手展示loading动画,刷新结束之后,整体复位。考虑另外一种情况,下拉到阈值了,继续下拉,header漏出来了,没有松手,再往上啦,直到header隐藏,那么继续往上,应该是RecyclerView“可以滑动”了,属于正常的RecyclerView自己的操作,RecyclerView可以“滑动了”。

  • RecyclerView“滑不动”,应该是不接受事件,“可以滑动”可以接受事件了,负责给RecyclerView分发时间的,指定就是包裹它的ViewGroup,那么事件分发重写哪一个方法,或者重写哪些方法呢?

    • onTouchEvent,这个方法主要是用来处理事件,做一些手势操作,监测左滑右滑,以及滑动距离速度等。当RecyclerView“不能动”,应该就是不接受事件了,处理不了,我只能设置让父View不拦截,不能设置父View拦截。有人说,我不处理不就行了,能到我还要设置RecyclerView的事件分发return false??这个就不是无侵入式了,而且越来越复杂,不可取。

    • onIntercept,这个方法主要用来判断当前View是否拦截此事件,而且这个方法不是一直调用的。考虑一种情况,当RecyclerView在滑动的时候,很显然RecyclerView要接受事件的,当达到阈值的时候,“滑不动”了,这个时候事件不能接受,需要父ViewGroup控制整体滑动,展示header。当手指按下到抬起,这一系列事件,如果RecyclerView处理了,很显然哪怕到达阈值,事件依旧需要RecyclerView处理。但是我们希望到达阈值,不能处理了,需要给父ViewGoup处理,实现下拉刷新。刚开始自己处理,判断RecyclerView有没有到顶就知道有没有到达阈值,没有到顶,就让RecyclerView处理,否自自己处理,这个可以实现。但是呢,当自己处理的时候,有一种case。下拉到阈值,但是呢没有放手展示loading加载中,继续往上拉,直到header隐藏,任然继续,这个时候事件依旧是父ViewGroup处理。为什么呢?因为看过源码知道,当父ViewGroup自己处理事件的时候,onIntercept不糊走进去的,导致父View一致在处理事件,RecyclerView就无法正常滑动了。

    • dispatchTouchEvent,这个方法主要负责事件的分发,是事件分发的入口,每次的事件都会经过它,都会调用。既然这样,RecyclerView“滑不动”,我就控制事件入口不给RecyclerView分发事件,“可以滑动”,我就给RecyclerView分发事件不就行了吗,是的,可取。

下拉刷新库PTRFrameLayout

根据生面的分析,很显然,我们只需要分析PTRFrameLayout这个自定义View的dispatchTouchEvent即可。而且我建议大家,对照着我的注释,自己梳理一下代码逻辑,加强学习和理解。


// 这个方法走默认的事件分发逻辑
 public boolean dispatchTouchEventSupper(MotionEvent e) {
        return super.dispatchTouchEvent(e);
 }
 
 // 正常分发cancel事件,让子View忽略事件处理。
 private void sendCancelEvent() {
        // The ScrollChecker will update position and lead to send cancel event when mLastMoveEvent is null.
        // fix #104, #80, #92
        if (mLastMoveEvent == null) {
            return;
        }
        MotionEvent last = mLastMoveEvent;
        MotionEvent e = MotionEvent.obtain(last.getDownTime(), last.getEventTime() + ViewConfiguration.getLongPressTimeout(),
                MotionEvent.ACTION_CANCEL, last.getX(), last.getY(), last.getMetaState());
        dispatchTouchEventSupper(e);
 }
 
 @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
    
        // mHeaderView 是下拉刷新的头部  mContent 表示的内容区(比如RecyclerView)
        // 满足一下情况,不拦截任何事件,直接把事件分发下去
        if (!isEnabled() || mContent == null || mHeaderView == null || isDisabledForRefresh) {
            return dispatchTouchEventSupper(e);
        }
        int action = e.getAction();
        switch (action) {
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
               // 判断逻辑,看手势阈值以及当前状态,是否展示headerView,执行下拉刷新
                mPtrIndicator.onRelease();
                // 表明抬起手的时候,
                if (mPtrIndicator.hasLeftStartPosition()) {
                    onRelease(false);
                    // 表明抬起手指的时候,headerView漏出来了,触发了阈值,那么之间就是自己处理
                    // 同时把放下去的一些事件(放置产生onClick等)取消掉,发送cancel事件
                    // 其他情况下,都是走默认的事件分发逻辑,因为这个时候没有达到阈值,属于RecyclerView的正常滑动
                    if (mPtrIndicator.hasMovedAfterPressedDown()) {
                        sendCancelEvent();
                        return true;
                    }
                    return dispatchTouchEventSupper(e);
                } else {
                    return dispatchTouchEventSupper(e);
                }
            case MotionEvent.ACTION_DOWN:
                // 重置标志位
                mHasSendCancelEvent = false;
                // 更新按下的坐标
                mPtrIndicator.onPressDown(e.getX(), e.getY());

                // 如果当前在滑动,按住(很好理解,比如你一直下拉,拉到很长,松手收上去的时候,你是可以从新触摸屏幕,按住正在收起的控件的)
                mScrollChecker.abortIfWorking();

                mPreventForHorizontal = false;
                // The cancel event will be sent once the position is moved.
                // So let the event pass to children.
                // fix #93, #102
                // 分析一
                dispatchTouchEventSupper(e);
                return true;

            case MotionEvent.ACTION_MOVE:
                mLastMoveEvent = e;
                // 更新坐标,这几行代码都很好理解
                mPtrIndicator.onMove(e.getX(), e.getY());
                float offsetX = mPtrIndicator.getOffsetX();
                float offsetY = mPtrIndicator.getOffsetY();

                // 判断是不是需要禁止水平滑动
                if (mDisableWhenHorizontalMove && !mPreventForHorizontal && (Math.abs(offsetX) > mPagingTouchSlop
                        && Math.abs(offsetX) > Math.abs(offsetY))) {
                    if (mPtrIndicator.isInStartPosition()) {
                        mPreventForHorizontal = true;
                    }
                }
                
                // 如果不禁止水平滑动,那么用户左右滑动的时候,下拉刷新操作是不会走进来的,直接把左右滑动的事件放下去即可
                // 根据子View的返回值判断当前事件是不是需要消费
                if (mPreventForHorizontal) {
                    return dispatchTouchEventSupper(e);
                }

                boolean moveDown = offsetY > 0;
                boolean moveUp = !moveDown;
                boolean canMoveUp = mPtrIndicator.hasLeftStartPosition();

                // 没有达到下拉刷新的阈值,需要把事件放下去,因为这个时候PTRFrameLayout不需要出来,PTRFrameLayout需要处理的就是下拉刷新的时候而已
                // 是否处理,就看子View是否处理此事件即可
                // disable move when header not reach top
                if (moveDown && mPtrHandler != null && !mPtrHandler.checkCanDoRefresh(this, mContent, mHeaderView)) {
                    return dispatchTouchEventSupper(e);
                }

                // 如果当前达到了阈值,并且持续下拉(逐渐漏出了headerView),这个更新事件交给PTRFrameLayout处理
                // 如果headerView以及全部漏出来了,这个时候往上移动,还可以继续往上(headerView还没有合起来),那么这个事件也是交给PTRFrameLayout处理
                if ((moveUp && canMoveUp) || moveDown) {
                    if (mPtrHandler != null && !isStartDragging) {
                        mPtrHandler.onStartDragging();
                        isStartDragging = true;
                    }
                    movePos(offsetY);
                    return true;
                } else {
                    if (mPtrHandler != null && isStartDragging) {
                        isStartDragging = false;
                        mPtrHandler.onRefreshComplete();
                    }
                }
            default:
                return dispatchTouchEventSupper(e);
        }
    }

分析一:1,为什么要调用默认的时间分发方法?2,为什么要return true?

  • 1,首先调用dispatchTouchEventSupper(e),是为了把down事件都放下去,不干扰子View的正常响应。因为很多时间是需要down的,比如longClick,onClick都需要接受到down事件,如果,当前还没有到下拉刷新,我就是点击了RecyclerView的一个item,需要跳转某一个页面,但是你没有把down时间放下去,不就出问题了吗?那就有人问,如果我不想子View处理,你把down放下去了怎么办?那不还是有cancel吗,对不。
  • 之前说过,当一个事件序列的down事件被我消费了,那么这个序列都会交给我处理,是的,目的就是这个下拉刷新控件消费事件,接管所有的事件,保证这个事件序列都是我的。又有人问了,dispatchTouchEvent,不是默认每次都会走进来吗?我有必要return true吗?好问题,如果你不return true,那么这个down必须要有人消费,谁来消费?headerView还是contentView?假如给其中的一个消费了,后续事件理应也是它的,这个没问题吧,然后你在move事件的时候拦截,OK,说的通?但是如果他们都不消费,咋办?然后,你说他们不消费我在消费,这不是有毒吗?或者我不消费,我再抛上去,让PTRFrameLayout父亲消费,这个更不行了?你down事件不消费,PTRFrameLayout就不会受到后续事件了,也就没有headerView和contentView啥事了。所以,干脆PTRFrameLayout直接return true,接管所有事件,负责分发不就行了,是的,PTRFrameLayout的核心就是接管所有事件,统一调度,合适的时机,把事件放下去。

总结

  • PTRFrameLayout核心就是down事件来的时候,return true接管所有的事件。
  • 然后根据有没有达到下拉刷新的阈值,来决定move事件到底交给谁处理,自己处理的话,就给子View发送cancel事件(这个需要注意一下)。
  • 抬起手的时候,同样判断有没有触发了下拉刷新,触发了,就给子View发送cancel事件,return true自己处理,让子View不响应,否则就走正常的事件分发逻辑,把事件正常发下去即可。