SwipeRefreshLayout源码浅析

代码版本:support-v4 24.1.1 共1165行
将SwipeRefreshLayout在本文中简称为SRL

涉及到的知识点

从控件的使用效果来看,我们可以了解到关键的知识点如下:

  • 自定义ViewGroup:可以学习一下ViewGroup以及View的一些关键方法的使用
  • 滑动事件:触摸事件传递规则,简单的多点触控相关
  • 动画

本篇也从这三个方面来解读。

涉及到的关键方法

 SwipeRefreshLayout(Context context, AttributeSet attrs) 
 onMeasure (int widthMeasureSpec, int heightMeasureSpec)
 onLayout(boolean changed, int left, int top, int right, int bottom)
 onInterceptTouchEvent(MotionEvent ev) 
 onTouchEvent(MotionEvent ev)
 moveSpinner(float overscrollTop)

我们可以把整个下拉刷新过程分为几个关键的过程部分:

  • 下拉过程
  • 回弹过程
  • 转圈刷新过程

常量和方法
通过一些常量的定义我们可以认识到SRL的View组成部分,以及一些关键的时间点。

CircleImageView mCircleView(继承自:android.support.v7.widget.AppCompatImageView): 即我们下拉刷新的过程中可以见到的圈圈控件,在构造方法中为其设置了一系列属性.

MaterialProgressDrawable mProgress (继承自:Drawable):这是圆圈控件CircleImageView 的内容,在构造方法中可以看到:mCircleView.setImageDrawable(mProgress);对于圆圈内容的控制基本上都是通过MaterialProgressDrawable 来实现的。

View mTarget:一般即SRL的直接子View比如你嵌入的RecyclerView;在ensureTarget()方法中完成对其赋值。

float mTotalDragDistance:超过这个距离值后即认定为下拉刷新;也就是触发下拉刷新的距离;也就是最终停下来转圈圈的位置。

int mCurrentTargetOffsetTo:CircleView实时距离初始位置滑过的距离。

private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate):通过调用mCircleView的offsetTopAndBottom来移动CircleView本身。

public boolean canChildScrollUp():判断SRL的内容是否滑动到顶部,return false 说明滑动到顶部可以触发下拉刷新;return true说明未滑动到顶部不触发下拉刷新。该方法在onTouchEvent和onInterceptTouchEvent方法中均被调用来判断。

初始及化绘制过程:

构造方法
初始化各种常量字段,诸如mCircleView的大小,动画插值器,mTotalDragDistance,创建并添加mCircleView,设置一些属性,比如setWillNotDraw(false)可以提高效率。

绘制布局

@Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //确保mTarget不为空
        if (mTarget == null) {
            ensureTarget();
        }
        if (mTarget == null) {
            return;
        }
        //测量mTarget的宽高,去掉padding
        mTarget.measure(MeasureSpec.makeMeasureSpec(
                getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
                MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
                getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
        //测量mCircleView的宽高
        mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY));
        //如果没有使用自定义的起始位置,并且起始位置没有被计算过(一般第一次onMeaure的时候会被调用)
        if (!mUsingCustomStart && !mOriginalOffsetCalculated) {
            mOriginalOffsetCalculated = true;
          //计算出当前CircleView移动的位置,即CircleView的自然高度的负值,
          //也就是说CircleView正好在屏幕上边,我们看不到它
            mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleView.getMeasuredHeight();
        }
        mCircleViewIndex = -1;
        // Get the index of the circleview.
        // 获取到圆圈view在当前view中的位置;一般情况下mCircleViewIndex都为0;
        // 这个值会在getChildDrawingOrder被调用
        for (int index = 0; index < getChildCount(); index++) {
            if (getChildAt(index) == mCircleView) {
                mCircleViewIndex = index;
                break;
            }
        }
    }

onMeasure的代码和解释如上,做了两件事: 完成子View的大小测量,对变量进行正确赋值。
接下来在onLayout方法中来确定在mTarget和mCircleView在SRL中的位置,onLayout的代码清晰明了,就做了两件事:对mTarget调用layout方法确定其位置,对CircleView调用layout方法确定其位置,在这里就不贴代码啦。

滑动事件处理:

首先我们知道对于ViewGroup的滑动处理流程的伪代码如下:

public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean result = false;             // 默认状态为没有消费过

    if (!onInterceptTouchEvent(ev)) {   // 如果没有拦截交给子View
        result = child.dispatchTouchEvent(ev);
    }

    if (!result) {                      // 如果事件没有被消费,询问自身onTouchEvent
        result = onTouchEvent(ev);
    }

    return result;
}

在SRL中重写了onInterceptTouchEvent和onTouchEvent方法,通过完成一次完整的下拉刷新,打印Log,我们发现调用过程如下:

滑动事件

下面看onInterceptTouchEvent的处理,这里它会返回一个mIsBeingDragged (是否被拖拽的布尔值),返回true则正在被拖拽,这时就会把事件分发到事件本身的onTouchEvent中,反之就会交给子View去处理。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        ensureTarget();

        final int action = MotionEventCompat.getActionMasked(ev);
        // 设置准备开始的状态
        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }
        // 判断一系列状态,决定是否可以下拉刷新,注意canChildScrollUp()方法,
        // 该方法决定了只有滑动到顶部继续下来才能触发下拉刷新
        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:
                // 默认情况下offset为0,不移动;
                // 若初始指定了mOriginalOffsetTop 的大小则意味着,按下的一刻,被移动到了指定位置。
                setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
                mIsBeingDragged = false;
                final float initialDownY = getMotionEventY(ev, mActivePointerId);
                if (initialDownY == -1) {
                // 不在屏幕范围内不处理
                    return false;
                }
                // mInitialDownY 在ActionMove中用于与移动距离比较,判断是否被拖拽。
                mInitialDownY = initialDownY;
                //可以看到在当前动作下,设置了CircleView的初始位置;获取到了多点触控相关的手指id;拖拽状态置为false;赋值初始按下的位置值:mInitialDownY
                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;
                }

                final float y = getMotionEventY(ev, mActivePointerId);
                if (y == -1) {
                    return false;
                }
                final float yDiff = y - mInitialDownY;
                // 如果滑动的距离大于被视为滑动的最小距离,并且之前的状态为没有被拖动;
                // 这时把拖拽状态mIsBeingDragged置为true;同时记下按下的位置:mInitialMotionY
                //如果mIsBeingDragged为true就会return true;根据事件处理伪代码那么接下来的操作就交给onTouchEvent处理了
                if (yDiff > mTouchSlop && !mIsBeingDragged) {
                    mInitialMotionY = mInitialDownY + mTouchSlop;
                    mIsBeingDragged = true;
                    mProgress.setAlpha(STARTING_PROGRESS_ALPHA);
                }
                break;

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

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mIsBeingDragged = false;
                mActivePointerId = INVALID_POINTER;
                break;
        }

        return mIsBeingDragged;
    }

如上就是onInterceptTouchEvent所做的工作,设置初始位置,进行相关赋值操作,根据滑动的实际情况来决定是否把进一步操作转交给onTouchEvent。

public boolean onTouchEvent(MotionEvent ev) {
        final int action = MotionEventCompat.getActionMasked(ev);
        int pointerIndex = -1;
        // 首先是根据状态进行拦截
        if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
            mReturningToStart = false;
        }

        if (!isEnabled() || mReturningToStart || canChildScrollUp() || mNestedScrollInProgress) {
            // Fail fast if we're not in a state where a swipe is possible
            return false;
        }

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                // 获取触摸id,设置拖拽状态
                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
                mIsBeingDragged = false;
                break;

            case MotionEvent.ACTION_MOVE: {
                pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
                //排除无效状态
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
                    return false;
                }
                //获取滑动位置: y,y减去初始按下的位置在撑拖拽比率才是CircleView真正要划过的距离,
                //DRAG_RATE为0.5,所以CircleView滑过的距离,要比你手指移动的距离短。
                final float y = MotionEventCompat.getY(ev, pointerIndex);
                final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                if (mIsBeingDragged) {
                    if (overscrollTop > 0) {
                      //滑动距离大于0就去移动CircleView,下面这个方法很重要,就是依靠它来完成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 = MotionEventCompat.getPointerId(ev, pointerIndex);
                break;
            }

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

            case MotionEvent.ACTION_UP: {
                pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId);
                if (pointerIndex < 0) {
                    Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id.");
                    return false;
                }
                // 当结束下拉之后这时把拖拽状态置为false,通过finishSpinner来进行刷新操作或者是不到刷新距离回弹到初始位置的操作
                final float y = MotionEventCompat.getY(ev, pointerIndex);
                final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
                mIsBeingDragged = false;
                finishSpinner(overscrollTop);
                mActivePointerId = INVALID_POINTER;
                return false;
            }
            case MotionEvent.ACTION_CANCEL:
                return false;
        }

        return true;
    }

onTouchEvent分析到这里,我们知道了在这个方法中根据用户真实的滑动状况来调用相关方法:moveSpinner方法完成CircleView及其内容的变化,finishSpinner完成手指抬起后的刷新操作,接下来我们看这两个方法

private void moveSpinner(float overscrollTop) {
        mProgress.showArrow(true);// ture,设置下拉过程中展示小箭头
        //移动的距离除以刷新位置的距离得出一个拖拽比率:originalDragPercent 
        float originalDragPercent = overscrollTop / mTotalDragDistance;

        float dragPercent = Math.min(1f, Math.abs(originalDragPercent));
        float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3;
        float extraOS = Math.abs(overscrollTop) - mTotalDragDistance;
        float slingshotDist = mUsingCustomStart ? mSpinnerFinalOffset - mOriginalOffsetTop
                : mSpinnerFinalOffset;
        float tensionSlingshotPercent = Math.max(0, Math.min(extraOS, slingshotDist * 2)
                / slingshotDist);
        float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow(
                (tensionSlingshotPercent / 4), 2)) * 2f;
        float extraMove = (slingshotDist) * tensionPercent * 2;

        int targetY = mOriginalOffsetTop + (int) ((slingshotDist * dragPercent) + extraMove);
        // where 1.0f is a full circle
        if (mCircleView.getVisibility() != View.VISIBLE) {
            mCircleView.setVisibility(View.VISIBLE);
        }
        if (!mScale) {
            ViewCompat.setScaleX(mCircleView, 1f);
            ViewCompat.setScaleY(mCircleView, 1f);
        }

        if (mScale) {
            setAnimationProgress(Math.min(1f, overscrollTop / mTotalDragDistance));
        }
        if (overscrollTop < mTotalDragDistance) {
            if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA
                    && !isAnimationRunning(mAlphaStartAnimation)) {
                // Animate the alpha
                startProgressAlphaStartAnimation();
            }
        } else {
            if (mProgress.getAlpha() < MAX_ALPHA && !isAnimationRunning(mAlphaMaxAnimation)) {
                // Animate the alpha
                startProgressAlphaMaxAnimation();
            }
        }
       //设置内部圈圈开始出现时的大小
        float strokeStart = adjustedPercent * .8f;
        //设置小箭头的样式        
        mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart));
        mProgress.setArrowScale(Math.min(1f, adjustedPercent));

        float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f;
        mProgress.setProgressRotation(rotation);
        setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, true /* requires update */);
    }

在moveSpinner方法中通过对overscrollTop(CircileView移动的距离)和mTotalDragDistance(刷新位置的距离)进行一系列的计算得出在下拉过程中其他元素的变化程度。最后通过setTargetOffsetTopAndBottom方法来真是移动CircleView本身。

接下来我们看finishSpinner(float overscrollTop)方法,在onTouchEvent中拦截到ACTION_UP手势后调用到了 finishSpinner(overscrollTop);传入的overscrollTop为CircileView移动的距离。

private void finishSpinner(float overscrollTop) {
        if (overscrollTop > mTotalDragDistance) {
        //如果超过了CircleView最终下来刷新位置的距离后则认定为触发下拉刷新调用setRefreshing方法
            setRefreshing(true, true /* notify */);
        } else {
            //否则的话取消本次刷新动作
            // cancel refresh
            mRefreshing = false;
            mProgress.setStartEndTrim(0f, 0f);
            Animation.AnimationListener listener = null;
            //mScale默认为false 在这里监听动画结束来进一步操作
            if (!mScale) {
                listener = new Animation.AnimationListener() {

                    @Override
                    public void onAnimationStart(Animation animation) {
                    }

                    @Override
                    public void onAnimationEnd(Animation animation) {
                        if (!mScale) {
                            startScaleDownAnimation(null);
                        }
                    }

                    @Override
                    public void onAnimationRepeat(Animation animation) {
                    }

                };
            }
            // 在这里开始真正的回弹动画
            animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener);
            mProgress.showArrow(false);
        }
    }

在流程上的分析基本上到这里就结束了,其中一些自定义view和绘制的方法还需要进一步理解。

动画

在SRL中的动画实现方式都是如下套路:
首先直接继承View动画Animation来实现在动画过程中的相应操作。

private final Animation mAnimateToStartPosition = new Animation() {
        @Override
        public void applyTransformation(float interpolatedTime, Transformation t) {
                int targetTop = 0;
        targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime));
        int offset = targetTop - mCircleView.getTop();
       //基于Z轴,放到最上层
        mCircleView.bringToFront();
        mCircleView.offsetTopAndBottom(offset);
        mCurrentTargetOffsetTop = mCircleView.getTop();
        if (requiresUpdate && android.os.Build.VERSION.SDK_INT < 11) {
            //将触发onDraw方法
            invalidate();
        }
        }
    };

然后设置动画的相关属性:

  mAnimateToStartPosition.reset();
            mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DsURATION);
            mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); //减速返回

最后给View设置动画

 mCircleView.clearAnimation();
            mCircleView.startAnimation(mAnimateToStartPosition);

源码中的动画实现都是这一个套路就不多说了。

其他

我们看到SwipeRefreshLayout还实现了两个接口:NestedScrollingParent 和NestedScrollingChild来处理嵌套滑动,关于这个知识点打算再写一篇博客。

总结

到这里我们已经把SRL的基本实现方式和调用细节大致分析了一遍,主要的知识点还是自定义ViewGroup、 触摸事件的处理和动画的使用。另外关于CircleImageView和MaterialProgressDrawable的实现感兴趣的话还值得深入挖掘。

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

推荐阅读更多精彩内容