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文件先删掉。

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

时间: 2024-10-27 07:54:12

Android好奇宝宝_11_SwipeRefreshLayout原理浅析的相关文章

Android好奇宝宝_番外篇_看脸的世界_08

废话少说,先上效果图: (左侧的图片是我用window画图软件1分钟画的,所以就不要嫌丑了,You can you up no bb.) 这是我发过最挫的效果图了,不过这是由于没有图片素材导致的,就不要在意这些细节了,知道实现原理后完全可以发挥你的想象去实现更美观的效果. 这个效果也是有开源库的,不过我又把名字给忘了,不过我记得原理,于是就试着自己写了一下. 其实原理很简单,我在另一篇博客(一个有吃豆人删除动画的ListView)也说过了,这一篇当做兑换那些年少轻狂不更事时许下的诺言(是不是瞬间

Android好奇宝宝_04_一个有3个功能的Adapter

感觉Android好奇宝宝这个系列是脱离不了ListView和GridView了... 这一篇呢来分享点好东西 一个自定义Adapter,可以快速实现三个功能: (1)自动缓存处理 好吧,这个功能不是我实现的.我只是照搬鸿洋大大的,我会简单说下,不过还是请先看下他的原文,再来看我添加的两个功能,传送门 (2)支持item的不同布局 提供一个接口来通过position和该position的数据来设置不同的布局 (3)局部刷新 只刷新指定item的某个子View,避免一直调用notifyDataSe

Android好奇宝宝_番外篇_看脸的世界_05

上一篇番外篇讲了一个炒鸡炒鸡简单的自定义ProgressBar,这一篇基于上一篇的基础扩展为SeekBar,没看过上一篇的,请先看一遍:传送门 先上效果图(2G内存的机子运行模拟器,所以有点卡): 这个效果之前不知道在哪里看到过,我也忘了. 下面进入正题: 测量大小和绘制部分沿用上一篇ProgressBar的,不清楚的请走上面的传送门. 对比上一篇的扩展: (1)SeekBar能通过触摸改变刻度 (2)SeekBar上方添加一个显示当前刻度的浮动View(后面用FloatView表示) (1)通

Android好奇宝宝_番外篇_看脸的世界_03

无聊刷帖看到一个求助,试着写了一下. 一个自定义Switch控件,附带动画效果. 说是控件,其实是一个布局容器,先上效果图: 先讲原理,再看高清源码. 原理: 好像没啥原理,汗... 跟其它自定义容器控件一样,一般要注意: (1)计算好大小,宽度和高度 (2)计算好子View的布局位置 不是一般要注意的: (3)动画是用的nineoldandroids (4)遮挡效果是通过控制子View的绘制顺序 高清源码: (1)计算大小: protected void onMeasure(int width

Android好奇宝宝_06_聊一聊Android里的动画

这一篇我们来聊一聊高大上的动画效果. 首先说一个常识,一个对理解动画最重要的概念,亦是动画的本质: 动画的原理是利人眼的视觉暂留的特性,即如果一帧帧图像切换的足够快的话,人眼就察觉不到停顿,看起来就像连续的动画了. 动画的原理很简单,就是让图像进行快速的切换.动画的难点是计算出每两帧之间的差异,比如一个位移动画,对于每一帧你都必须计算出它的位置,如果是直线匀速的.很容易计算,但如果是曲线的而且还是有加速度(即移动的速度是会变化的)的,那么计算就会变的复杂了. 总结一下,动画有两个要素,一个是若干

Android好奇宝宝_07_ViewPager切换动画(兼容低版本)

闲着无聊,写写Demo 想着写一个图片轮播,百度了一下基本都是用ViewPager实现的,那就用ViewPager来练手. 写完了再自定义切换效果,发现3.0以下不兼容,只好想办法. 先上效果图: 下面一步一步来: (1)写布局: <RelativeLayout android:layout_width="match_parent" android:layout_height="wrap_content" > <jjj.demo.viewpager

Android好奇宝宝_05_PopupWindow与悬浮窗

这一篇讲讲PopupWindow与悬浮窗之间那些不得不说的故事. 之所以把PopupWindow与悬浮窗这两个放到一起讲,是因为这两个的实现原理基本是一致的,只是有点不同而已. 原理: 使用系统服务(WindowManagerService)将要显示的View添加进Window中. WindowManagerService和ActivityManagerService是Android系统中两个最重要的服务,其中一个管理窗口显示,一个管理四大组件. ActivityManagerService这里

Android好奇宝宝_番外篇_看脸的世界_02

一个有吃豆人删除动画的ListView 这是一个无聊的效果,由一个无聊的程序猿,在无聊的情况下写的. 虽然这效果不中看中用,不过就当学习了. 先上图 效果一目了然,主要是: (1)移除item时执行吃豆人动画 (2)滚动时吃豆人也相应移动 (3)应对可见与不可见状态间的切换 简单原理分析: (1)吃豆人.豆.和左边的白色矩形(当然所有颜色都是可以改的,你想换成图片也行)都是用canvas画出来的. (2)问:canvas那里来的?答:ListView的canvas.具体是重写ListView的这

Android之SharedPreferences内部原理浅析

SharedPreferences内部工作原理: 1.调用getSharedPreferences();创建一个SharedPreferences对象,其中会先判断是否存在对应xml文件,如果发现存在则会有一个预加载操作,这个操作是把xml文件的内容通过I/O操作和XmlUitl解析后存入一个map对象中,所以我们调用SharedPreferences::getString();等get操作实际上是不会对文件做I/O操作,而是直接访问刚刚的map集合的内容,这提高了效率,如果对应的xml不存在则