SwipeRefreshLayout源码分析

SwipeRefreshLayout已经推出许久了,很多App都在使用,这里对其实现方式做个分析。下拉刷新控件其实是很好的学习Android的Touch事件传递的用例,尤其是其中onInterceptTouchEvent()onTouchEvent()方法的实现,对于自定义ViewGroup的事件处理部分有借鉴意义。

这篇文章分析传统的基于Touch事件传递流程的下拉刷新逻辑。(还有一个逻辑分支是NestedScroll,先留个坑。)

原文地址

总览

下拉刷新的实现思路并不难,如果了解过Touch事件传递的流程,就不难想到:

  1. 自定义ViewGroup包裹在需要刷新的内容View外层。
  2. onInterceptTouchEvent()方法中判断是否应当触发下拉刷新,一般判断条件都是内容View已经滚动到顶部。
  3. 拦截事件并交给自身的onTouchEvent()方法处理。
  4. onTouchEvent()方法中处理Touch事件,包括根据刷新的状态更新UI,触发刷新监听器等。

这就是最核心的下拉刷新的逻辑,下面看一下SwipeRefreshLayout是怎么实现的,又有什么值得学习的地方。

Support包版本为25.1.0

onInterceptTouchEvent(MotionEvent ev)

onInterceptTouchEvent()可以看做是下拉刷新流程的其实位置,Touch事件传递到SwipeRefreshLayout中,会先执行onInterceptTouchEvent()方法,通过其返回值决定继续向下传递还是让SwipeRefreshLayout作为后续事件的消费者。

这个方法中包含如下逻辑:

  1. 如果还没有确定需要刷新的View,找到刷新的View。

  2. 排除5种不应该刷新的状态。(不可用、正在复位、子View还可以下拉、正在刷新、处于NestedScroll状态)

    如果当前正在复位,并且收到了DOWN事件,则忽略复位状态。

  3. 如果是DOWN事件,记录初始位置和事件的pointerId(手指)。

  4. 如果是MOVE事件,如果滑动距离超过阈值,标记进入下拉刷新状态,将使这个方法返回true,后续事件由onTouchEvent()处理。

  5. 如果是POINTER_UP事件(非主要手指抬起),重新记录pointerId。

  6. 如果是UP事件,退出刷新状态,清除pointerId记录。

源码

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        // 确定刷新的View,这个View会赋值给mTarget属性,后续判断是否可以下拉会使用到。
        ensureTarget();

        final int action = MotionEventCompat.getActionMasked(ev);
        int pointerIndex;

        // DOWN事件时忽略复位状态
        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }

        // 5个条件满足一个,就不处理事件,让事件向下传递。
        if (!isEnabled() || mReturningToStart || canChildScrollUp()
                || mRefreshing || mNestedScrollInProgress) {
            // Fail fast if we're not in a state where a swipe is possible
            return false;
        }

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 移动CircleView到初始值(方法名用了Target这个单词,我认为不妥。)
                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
                mActivePointerId = ev.getPointerId(0);
                mIsBeingDragged = false;

                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                // 记录初始按下位置
                mInitialDownY = ev.getY(pointerIndex);
                break;

            case MotionEvent.ACTION_MOVE:
                if (mActivePointerId == INVALID_POINTER) {
                    Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
                    return false;
                }

                pointerIndex = ev.findPointerIndex(mActivePointerId);
                if (pointerIndex < 0) {
                    return false;
                }
                // 当前事件的Y值。
                final float y = ev.getY(pointerIndex);
                // 虽然方法名字叫做startDragging,但其实里面进行了判断,是否应该拦截事件。(方法名不妥)
                startDragging(y);
                break;

            case MotionEventCompat.ACTION_POINTER_UP:
                // 重新标记激活的pointerId
                onSecondaryPointerUp(ev);
                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                // 停止拦截事件
                mIsBeingDragged = false;
                mActivePointerId = INVALID_POINTER;
                break;
        }

        return mIsBeingDragged;
    }

需要注意onSecondaryPointerUp()方法:

    private void onSecondaryPointerUp(MotionEvent ev) {
        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mActivePointerId = ev.getPointerId(newPointerIndex);
        }
    }

这个方法的实现只支持最多两个手指的切换,如果有第三个触摸点,就会出现bug。相似的逻辑在NestedScrollView中也出现了,并且其代码里面包含TODO:

TODO: Make this decision more intelligent.

onTouchEvent(MotionEvent ev)

这个方法的核心逻辑就是调用moveSpinner方法和finishSpinner方法。这两个方法中分别对应【手指移动时拖拽CircleView移动并且更新CircleView上面箭头的样式】以及【Touch事件结束时判断复位或者进入刷新状态】。

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        // 省略了一些代码……
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 省略了一些代码……

            case MotionEvent.ACTION_MOVE: {
                // 省略了一些代码……
                
                if (mIsBeingDragged) {
                    // mInitialMotionY等于(DOWN事件的坐标 + mTouchSlop),DRAG_RATE等于0.5f
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    // 是否需要移动CircleView
                    if (overscrollTop > 0) {
                        // 移动CircleView
                        moveSpinner(overscrollTop);
                    } else {
                        return false;
                    }
                }
                break;
            }
            case MotionEventCompat.ACTION_POINTER_DOWN: {
                // 有新手指按下,标记新手指。
                pointerIndex = MotionEventCompat.getActionIndex(ev);
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG,
                            "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                    return false;
                }
                mActivePointerId = ev.getPointerId(pointerIndex);
                break;
            }

            case MotionEventCompat.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;

            case MotionEvent.ACTION_UP: {
                // 省略了一些代码……

                if (mIsBeingDragged) {
                    final float y = ev.getY(pointerIndex);
                    final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                    mIsBeingDragged = false;
                    // 复位CircleView或者移动到刷新状态的位置(getTop() == 64dp)
                    finishSpinner(overscrollTop);
                }
                mActivePointerId = INVALID_POINTER;
                return false;
            }
            case MotionEvent.ACTION_CANCEL:
                return false;
        }

        return true;
    }

moveSpinner()方法实现了CircleView位置的计算以及箭头属性的计算,可以跳过。

finishSpinner()方法判断滑动距离是否超过了阈值,超过的话调用setRefresh(boolean, boolean)方法触发刷新回调:

    private void finishSpinner(float overscrollTop) {
        if (overscrollTop > mTotalDragDistance) {
            // 触发刷新
            setRefreshing(true, true /* notify */);
        } else {
            // cancel refresh
            mRefreshing = false;
            // 省略一些代码……
        }
    }

setRefresh()方法:

    private void setRefreshing(boolean refreshing, final boolean notify) {
        if (mRefreshing != refreshing) {
            mNotify = notify;
            ensureTarget();
            mRefreshing = refreshing;
            if (mRefreshing) {
                // 移动到刷新位置。
                animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
            } else {
                // 停止刷新时的处理,执行CircleView的缩小动画。
                startScaleDownAnimation(mRefreshListener);
            }
        }
    }

注意animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);这一行,回调的逻辑在mRefreshLinstener里面:

    private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
        // 省略一些代码
        @SuppressLint("NewApi")
        @Override
        public void onAnimationEnd(Animation animation) {
            if (mRefreshing) {
                // Make sure the progress view is fully visible
                mProgress.setAlpha(MAX_ALPHA);
                mProgress.start();
                if (mNotify) {
                    // 通知回调
                    if (mListener != null) {
                        mListener.onRefresh();
                    }
                }
                mCurrentTargetOffsetTop = mCircleView.getTop();
            } else {
                reset();
            }
        }
    };

当执行mListener.onRefresh()方法时,就是执行我们熟悉的回调方法了。

刷新结束之后,调用setRefreshing(false);方法时,也会执行到上面两个参数的setRefreshing(false, false)方法,执行缩小动画。

canChildScrollUp

下拉刷新逻辑中的一个关键判断就是判断子View是否已经滑动到最顶端,SwipeRefreshLayout使用canChildScrollUp()方法进行这个判断:

    public boolean canChildScrollUp() {
        if (mChildScrollUpCallback != null) {
            return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
        }
        if (android.os.Build.VERSION.SDK_INT < 14) {
            if (mTarget instanceof AbsListView) {
                final AbsListView absListView = (AbsListView) mTarget;
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                                .getTop() < absListView.getPaddingTop());
            } else {
                return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0;
            }
        } else {
            return ViewCompat.canScrollVertically(mTarget, -1);
        }
    }

除了SDK版本14以下对于ListView的特殊处理,都使用ViewCompat.canScrollVertically(mTarget, -1);这个方法进行判断。最终会执行下面的判断逻辑:

    private boolean canScrollingViewScrollVertically(ScrollingView view, int direction) {
        final int offset = view.computeVerticalScrollOffset();
        final int range = view.computeVerticalScrollRange() -
                view.computeVerticalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }

其中的关键数值offset最终还是会从View的mScrollY属性获取,和getScrollY()获取到的是同一个值。

这里需要注意的问题是direction的认定。ViewCompat.canScrollVertically(mTarget, -1);这个方法的参数-1以及canChildScrollUp()的方法名,都包含了UP这个方向,但是我们判断是否到顶了不应该是判断【是否能向下滚动】吗,为什么是相反的呢?

原因要从mScrollY这个参数上找,mScrollY的含义其实是View相对于内容的偏移量:

上图中,mScrollY的值实际上内容坐标系中View显示区域的偏移量。图中的mScrollY的符号位正。也就是我们通常所说的“上拉”对应mScrollY的值为正值,反之负值就对应“下拉”了,也就是上文提到的UP

-1还可以理解为使mScrollY减小的方向,自然也就是“下拉”了。

总之,这里确实有点绕。

关于Draw

SwipeRefreshLayout中,还有几个和绘制相关的点,值得关注一下。

setWillNotDraw(boolean):这个方法关联到ViewGroup的一个flag,默认情况下为true,也就是自身不需要进行绘制,底层会根据这个flag进行优化。需要绘制的话,需要将flag置为true。

ViewCompat.setChildrenDrawingOrderEnabled(ViewGroup viewGroup, boolean enable):通常我们自定义ViewGroup时需要将某个View在顶层绘制,都是调用View.bringToFront();方法将其移动到最顶层,但是这个方法有一个副作用,后面会提到。而ViewCompat的这个方法提供了另一种解决方案。

ViewGroup在绘制子View时,如果之前调用了setChildrenDrawingOrderEnabled()设置为true,会调用getChildDrawingOrder()重新确定每个子View的绘制顺序,也就可以实现将某个View的顺序放置到顶层了。SwipeRefreshLayout的实现如下:

    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        if (mCircleViewIndex < 0) {
            return i;
        } else if (i == childCount - 1) {
            // Draw the selected child last
            return mCircleViewIndex;
        } else if (i >= mCircleViewIndex) {
            // Move the children after the selected child earlier one
            return i + 1;
        } else {
            // Keep the children before the selected child the same
            return i;
        }
    }

解释一下,第一个参数很好理解,第二个参数是迭代位置,返回值是子View的index,这个方法的作用可以理解为:第i次应该绘制哪个子View,默认实现是return i;。也就是按照子View的顺序绘制。针对上面的实现,假设mCircleViewIndex的值为2,childCount的值为6,那么会得到如下结果。

是不是很有趣?

Measure 和 Layout:Measure的过程中,对于mTarget,忽略LayoutParams参数,直接设置为填满父控件的值。Layout过程中,只对mCircleView和mTarget两个View进行布局。这些都是常用的行之有效的处理方法。

总结

以上就是对SwipeRefreshLayout的分析,当然开头提到了,只是Touch事件的逻辑分支,NestedScroll相关的内容,就留到下次啦。

原文地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,688评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,559评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,749评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,581评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,741评论 3 271
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,684评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,122评论 2 292
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,847评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,441评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,939评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,333评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,783评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,275评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,830评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,444评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,553评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,618评论 2 249

推荐阅读更多精彩内容