首页 > 代码库 > Android好奇宝宝_11_SwipeRefreshLayout原理浅析

Android好奇宝宝_11_SwipeRefreshLayout原理浅析

上一篇文章写了一个RecyclerView的Demo,然后就想加个下拉刷新功能进去试试,由于RecyclerView算比较新的东西,所以暂时找不到什么开源库使用。于是想到了官方提供的SwipeRefreshLayout,号称能为任何View添加下拉刷新功能。


SwipeRefreshLayout的使用很简单:


(1)将要下拉刷新的View嵌套到SwipeRefreshLayout中:

    <jjj.demo.newstuffdemo.JJJSwipeRefreshLayout
        android:id="@+id/swipe_refresh"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_below="@+id/title" >

        <jjj.demo.newstuffdemo.JJJRecyclerView
            android:id="@+id/recyclerview"
            android:layout_width="match_parent"
            android:layout_height="match_parent" >
        </jjj.demo.newstuffdemo.JJJRecyclerView>
    </jjj.demo.newstuffdemo.JJJSwipeRefreshLayout>


(2)设置显示样式和刷新事件触发时的监听:

		swipeLayout = (JJJSwipeRefreshLayout) findViewById(R.id.swipe_refresh);
		swipeLayout.setColorScheme(android.R.color.holo_red_light, android.R.color.holo_green_light,
				android.R.color.holo_blue_bright, android.R.color.holo_orange_light);
		swipeLayout.setOnRefreshListener(this);


(3)刷新事件发生时进行处理:

	public void onRefresh() {
		//模拟耗时任务
		swipeLayout.postDelayed(new Runnable() {

			@Override
			public void run() {
				//记住调用setRefreshing(false)来表明刷新事件结束
				swipeLayout.setRefreshing(false);
				datas.get(0).text = "JJJ";
				mAdapter.notifyItemChanged(0);
			}
		}, 2000);
	}

当然,SwipeRefreshLayout的使用太简单了,这里肯定不会只是说它的用法。


因为SwipeRefreshLayout几乎在所有View(后面简称TargetView)里都能这么简单的添加下拉刷新功能,于是我好奇它是怎么实现的,既然好奇,就开始研究。


首先先猜测一下可能的实现思路:

因为SwipeRefreshLayout是TargetView的parent,所以触摸事件都要先经过它,于是呢SwipeRefreshLayout可以通过判断TargetView是否已经下拉到顶部无法继续下拉了。若是的话,则自己接管触摸事件,拦截事件不再分发给TargetView,SwipeRefreshLayout自己就可以通过这些触摸事件进行一些动作表示现在正在进行下拉刷新,比如默认实现就是显示一个类似圆形ProgressBar的自定义View:CircleImageView。


分析实现的难点:

(1)显示CircleImageView,有触摸事件,想让CircleImageView有移动、透明度变化等效果并不算难,主要是一些数值运算,不是我们的重点。


(2)怎么去判断一个View已经下拉到顶部,无法继续下拉了呢?这个问题就是我们讨论的重点。


开始到源码中寻找答案,SwipeRefreshLayout要判断不同的情况来决定是拦截触摸事件自己处理,还是分发给TargetView处理,那这个判断肯定是在onInterceptTouchEvent中进行,下面是SwipeRefreshLayout的超级阉割版的onInterceptTouchEvent方法源码:


(对触摸事件分发不熟悉的,请参考我另一篇博文:传送门)


    public boolean onInterceptTouchEvent(MotionEvent ev) {
    	//先忽略这句
        if (!isEnabled() || mReturningToStart || canChildScrollUp() || mRefreshing) {
            // Fail fast if we're not in a state where a swipe is possible
            return false;
        }

        switch (action) {
            case MotionEvent.ACTION_DOWN:
            	//ACTION_DOWN时记录下Y坐标
                mInitialMotionY = initialMotionY;

            case MotionEvent.ACTION_MOVE:
            	//得到Move时的Y坐标(getMotionEventY时对多指触摸时的处理,这里不鸟它)
                final float y = getMotionEventY(ev, mActivePointerId);
                if (y == -1) {
                    return false;
                }
                //计算出于Down时的偏移量yDiff
                final float yDiff = y - mInitialMotionY;
                //判断偏移量是否大于mTouchSlop且当前不处于拖动状态
                if (yDiff > mTouchSlop && !mIsBeingDragged) {
                	//设置拖动状态为true
                    mIsBeingDragged = true;
                }
                break;


            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            	//手指抬起时,设置拖动状态为false
                mIsBeingDragged = false;
                break;
        }
        //将SwipeRefreshLayout是否处于拖动状态作为返回值
        return mIsBeingDragged;
    }

几点解释:

(1)mTouchSlop:是指能被认定为拖动动作的最小距离,一般我们在写一些自定义View时会定义一个常量作为判断手指移动了多少才认为用户是在进行拖动,但其实官方提供了更好的方法来定义这个量,它会根据屏幕密度的不同定义不同的量,SwipeRefreshLayout中mTouchSlop的定义:

mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

(2)返回值mIsBeingDragged:对android比较熟悉一点的应该都知道onInterceptTouchEvent返回值含义,这里将SwipeRefreshLayout是否处于拖动状态作为返回值表示:

SwipeRefreshLayout处于拖动状态时(即mIsBeingDragged==true),onInterceptTouchEvent返回true,SwipeRefreshLayout拦截触摸事件,不分发给TargetView。

SwipeRefreshLayout不处于拖动状态时(即mIsBeingDragged==false),onInterceptTouchEvent返回false,SwipeRefreshLayout拦截触摸事件,将事件分发给TargetView进行处理。


小结一下,当处于下拉状态,且偏移距离大到能认定为一个拖动动作时,SwipeRefreshLayout将拦截事件自己进行处理。


可是TMD不对啊,如果真是这样的话,那么TargetView压根就接受不到下拉拖动动作,根本就无法向上滚动。正确的做法应该加上一个条件,就是SwipeRefreshLayout你想拦截我的下拉拖动动作,你得先确定我已经下拉到顶部了,无法再下拉了,不然老子跟你没完。


其实SwipeRefreshLayout是很友好的,它确实是这么做的,重新看上面那句我注释先忽略的if语句:

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

还有官方注释,我简单意译下:

如果SwipeRefreshLayout没有可能进行下拉,直接返回false,不要在那胡搞瞎搞,浪费时间。(我这英语水平,连我自己都陶醉了)


接下来意译下if语句:

如果SwipeRefreshLayout没有启用(isEnabled()==false),(都没启用,刷新个毛)

或者处于刷新事件刚结束正在恢复初始状态时(mReturningToStart==true),(上一次都还没结束,急什么)

或者子View(即TargetView)还可以继续往下拉(canChildScrollUp()==true),(TargetView还可以往下拉

或者正处于刷新状态(mRefreshing==true)时,(同第二,老子还没忙完,别烦我)

处于上面四种状态时,SwipeRefreshLayout会直接放弃对触摸事件的拦截,因为这四种状态下SwipeRefreshLayout并不符合进入下拉刷新的条件。


接下来主要分析SwipeRefreshLayout是如何判断TargetView能否继续往下拉的:

    public boolean canChildScrollUp() {
        if (android.os.Build.VERSION.SDK_INT < 14) {
        	//如果SDK版本小于14
            if (mTarget instanceof AbsListView) {
            	//如果TargetView是AbsListView类型
            	//即ListView或GridView
                final AbsListView absListView = (AbsListView) mTarget;
                //当absListView的item个数大于0(没有内容怎么滚?),
                //且第一个可见的item的position大于0
                //或者第一个item的顶部坐标小于absListView的PaddingTop
                return absListView.getChildCount() > 0
                        && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
                                .getTop() < absListView.getPaddingTop());
            } else {
            	//如果不是AbsListView
                return mTarget.getScrollY() > 0;
            }
        } else {
        	//如果SDK版本大于等于14
            return ViewCompat.canScrollVertically(mTarget, -1);
        }
    }

这里sdk小于14时的判断请看注释,最讨厌这种版本判断的if语句了,一看到就感觉整个人都不好不好的了。


ViewCompat.canScrollVertically方法会进行大量版本判断,看得我都想吐了,不过我们是固定在大于14时调用,所以固定会去调用mTarget.canScrollVertically(int direction)方法:

    public boolean canScrollVertically(int direction) {
        final int offset = computeVerticalScrollOffset();
        final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
        if (range == 0) return false;
        if (direction < 0) {
            return offset > 0;
        } else {
            return offset < range - 1;
        }
    }


direction参数为要判断的方向:

小于0:是否可以向下拉(对应向上滚)
大于等于0:是否可以向上拉(对应向下滚)

可以看到前面要判断的是是否可以继续下拉,所以传入的是一个负值-1。


这里有3个计算方法(PS:后面简称3大法),经我研究发现它们的含义分别为(如有错误,欢迎指正):

(1)computeVerticalScrollOffset():

已经向下滚动的距离,为0时表示已处于顶部。

(2)computeVerticalScrollRange():

整体的高度,注意是整体,包括在显示区域之外的。

(3)computeVerticalScrollExtent():

显示区域的高度。


图示如下:

技术分享

对比图示很容易知道:

当Offset大于0时,可以继续下拉,当Offset等于0时,不可以。

当Range大于Offset加上Extent时,可以继续上拉,当Range等于Offset加上Extent时,不可以。

(可以看到上面判断上拉时多减了一个1,是因为计算过程中有些float和int的转换,多减个1是为了保险起见,最多也就是吃掉view一个像素的高度而已)


小结:

ViewCompat.canScrollVertically方法确实用起来很方便,遗憾的是存在版本限制问题,要使用的话必须像SwipeRefreshLayout一样先进行版本判断。不过如果有需要判断一个View是否可以继续下拉上拉的需求的话,可以参照SwipeRefreshLayout的canChildScrollUp方法,既然是官方出的,可靠性应该还是可以的。


一个官方Bug的发现:

上面刚说官方可靠,下面马上说官方的bug。。。就是喜欢这样打自己脸。

这个bug的是我在尝试改造SwipeRefreshLayout来为RecyclerView添加上拉加载更多功能时发现的。

RecyclerView不熟悉的请参考我另一篇博文:传送门

当然首先先实验下ViewCompat.canScrollVertically能不能正确判断RecyclerView是否能继续上拉,一试,坑爹了,到底了依旧返回true。

思考问题所在:ViewCompat.canScrollVertically判断的正确性取决于3大法的正确性,不同的滚动View一般需要有不同的计算逻辑,它们必须去重写View类的3大法,实现自己的计算逻辑,如果3大法中有一个计算逻辑出错的话,将导致最终结果出错。

开始追寻RecyclerView是怎么实现3大法的,发现直接将任务交给了LayoutManager:

    protected int computeVerticalScrollOffset() {
    	//mLayout就是LayoutManager
        return mLayout.canScrollVertically() ? mLayout.computeVerticalScrollOffset(mState) : 0;
    }

很合理,因为不同布局样式的计算方式很可能是不一样的,由于我是在用LinearLayoutManager时出了问题,于是看LinearLayoutManager的实现,发现它又把任务交给了ScrollbarHelper这个帮助类:

    private int computeScrollOffset(RecyclerView.State state) {
        if (getChildCount() == 0) {
            return 0;
        }
        return ScrollbarHelper.computeScrollOffset(state, mOrientationHelper,
                getChildClosestToStart(), getChildClosestToEnd(), this,
                mSmoothScrollbarEnabled, mShouldReverseLayout);
    }

根据打印出来的数值,Range和Extent的计算是没问题的,但是Offset总是会小了一个Item的高度,所以看看Offset的计算哪里出问题了:

	static int computeScrollOffset(RecyclerView.State state, OrientationHelper orientation, View startChild,
			View endChild, RecyclerView.LayoutManager lm, boolean smoothScrollbarEnabled, boolean reverseLayout) {
		if (lm.getChildCount() == 0 || state.getItemCount() == 0 || startChild == null || endChild == null) {
			return 0;
		}
		final int minPosition = Math.min(lm.getPosition(startChild), lm.getPosition(endChild));
		final int maxPosition = Math.max(lm.getPosition(startChild), lm.getPosition(endChild));
		// itemsBefore表示第一个可见的item前面不可见的、完整的item数量
		//这里我们只关注正常情况下reverseLayout==false的情况
		//即itemsBefore=Math.max(0,minPosition - 1);
		final int itemsBefore = reverseLayout ? Math.max(0, state.getItemCount() - maxPosition - 1) : Math.max(0,
				minPosition - 1);
		if (!smoothScrollbarEnabled) {
			return itemsBefore;
		}
		final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild)
				- orientation.getDecoratedStart(startChild));
		final int itemRange = Math.abs(lm.getPosition(startChild) - lm.getPosition(endChild)) + 1;
		// avgSizePerRow表示每一个item的高度
		final float avgSizePerRow = (float) laidOutArea / itemRange;
		// 最终结果为itemsBefore乘以item的高度加上顶部第一项未显示出来的部分高度
		return Math.round(itemsBefore * avgSizePerRow
				+ (orientation.getStartAfterPadding() - orientation.getDecoratedStart(startChild)));
	}

对比图示很容易看出是那里计算出错了:

技术分享

(请忽略item之间的间隙)


没错,就是itemsBefore计算出错了。如上图当minPosition==2时,itemsBefore应该为2,而不是minPosition-1,itemsBefore应该就等于minPosition。


就是因为这里少算了一个item,于是最终计算出来的Offset与正确结果小了一个item的高度。


Bug修复方案也只是把“-1”去掉就行了,如果你真的需要用到这个的时候官方还没修复这个Bug的话,你可以自己下载

一个ScrollbarHelper文件然后改正后放到对应的包路径下,不过要记得把原有的RecyclerView的Jar包中的ScrollbarHelper文件先删掉。


本篇完结,开始进入过年倒计时阶段。

Android好奇宝宝_11_SwipeRefreshLayout原理浅析