SwipeRefreshLayout已经推出许久了,很多App都在使用,这里对其实现方式做个分析。下拉刷新控件其实是很好的学习Android的Touch事件传递的用例,尤其是其中onInterceptTouchEvent()
和onTouchEvent()
方法的实现,对于自定义ViewGroup的事件处理部分有借鉴意义。
这篇文章分析传统的基于Touch事件传递流程的下拉刷新逻辑。(还有一个逻辑分支是NestedScroll,先留个坑。)
总览
下拉刷新的实现思路并不难,如果了解过Touch事件传递的流程,就不难想到:
- 自定义ViewGroup包裹在需要刷新的内容View外层。
- 在
onInterceptTouchEvent()
方法中判断是否应当触发下拉刷新,一般判断条件都是内容View已经滚动到顶部。 - 拦截事件并交给自身的
onTouchEvent()
方法处理。 - 在
onTouchEvent()
方法中处理Touch事件,包括根据刷新的状态更新UI,触发刷新监听器等。
这就是最核心的下拉刷新的逻辑,下面看一下SwipeRefreshLayout是怎么实现的,又有什么值得学习的地方。
Support包版本为25.1.0
onInterceptTouchEvent(MotionEvent ev)
onInterceptTouchEvent()
可以看做是下拉刷新流程的其实位置,Touch事件传递到SwipeRefreshLayout中,会先执行onInterceptTouchEvent()
方法,通过其返回值决定继续向下传递还是让SwipeRefreshLayout作为后续事件的消费者。
这个方法中包含如下逻辑:
如果还没有确定需要刷新的View,找到刷新的View。
排除5种不应该刷新的状态。(不可用、正在复位、子View还可以下拉、正在刷新、处于NestedScroll状态)
如果当前正在复位,并且收到了DOWN事件,则忽略复位状态。
如果是DOWN事件,记录初始位置和事件的pointerId(手指)。
如果是MOVE事件,如果滑动距离超过阈值,标记进入下拉刷新状态,将使这个方法返回true,后续事件由
onTouchEvent()
处理。如果是POINTER_UP事件(非主要手指抬起),重新记录pointerId。
如果是UP事件,退出刷新状态,清除pointerId记录。
源码
1 |
|
需要注意onSecondaryPointerUp()
方法:
1 | private void onSecondaryPointerUp(MotionEvent ev) { |
这个方法的实现只支持最多两个手指的切换,如果有第三个触摸点,就会出现bug。相似的逻辑在NestedScrollView
中也出现了,并且其代码里面包含TODO:
TODO: Make this decision more intelligent.
onTouchEvent(MotionEvent ev)
这个方法的核心逻辑就是调用moveSpinner
方法和finishSpinner
方法。这两个方法中分别对应【手指移动时拖拽CircleView移动并且更新CircleView上面箭头的样式】以及【Touch事件结束时判断复位或者进入刷新状态】。
1 |
|
moveSpinner()
方法实现了CircleView位置的计算以及箭头属性的计算,可以跳过。
finishSpinner()
方法判断滑动距离是否超过了阈值,超过的话调用setRefresh(boolean, boolean)
方法触发刷新回调:
1 | private void finishSpinner(float overscrollTop) { |
setRefresh()
方法:
1 | private void setRefreshing(boolean refreshing, final boolean notify) { |
注意animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
这一行,回调的逻辑在mRefreshLinstener
里面:
1 | private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() { |
当执行mListener.onRefresh()
方法时,就是执行我们熟悉的回调方法了。
刷新结束之后,调用setRefreshing(false);
方法时,也会执行到上面两个参数的setRefreshing(false, false)
方法,执行缩小动画。
canChildScrollUp
下拉刷新逻辑中的一个关键判断就是判断子View是否已经滑动到最顶端,SwipeRefreshLayout使用canChildScrollUp()
方法进行这个判断:
1 | public boolean canChildScrollUp() { |
除了SDK版本14以下对于ListView的特殊处理,都使用ViewCompat.canScrollVertically(mTarget, -1);
这个方法进行判断。最终会执行下面的判断逻辑:
1 | private boolean canScrollingViewScrollVertically(ScrollingView view, int direction) { |
其中的关键数值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的实现如下:
1 |
|
解释一下,第一个参数很好理解,第二个参数是迭代位置,返回值是子View的index,这个方法的作用可以理解为:第i次应该绘制哪个子View,默认实现是return i;
。也就是按照子View的顺序绘制。针对上面的实现,假设mCircleViewIndex
的值为2,childCount
的值为6,那么会得到如下结果。
是不是很有趣?
Measure 和 Layout:Measure的过程中,对于mTarget,忽略LayoutParams参数,直接设置为填满父控件的值。Layout过程中,只对mCircleView和mTarget两个View进行布局。这些都是常用的行之有效的处理方法。
总结
以上就是对SwipeRefreshLayout的分析,当然开头提到了,只是Touch事件的逻辑分支,NestedScroll相关的内容,就留到下次啦。