SwipeRefreshLayout+RecyclerView无法下拉问题排查
Posted 沈页
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SwipeRefreshLayout+RecyclerView无法下拉问题排查相关的知识,希望对你有一定的参考价值。
背景
在android开发中,SwipeRefreshLayout
+RecyclerView
的UI模式帮助开发者很方便地提供了下拉刷新的能力,但在之前的开发中,我遇到了一个SwipeRefreshLayout无法下拉的问题。
具体的问题是,我需要通过RecyclerView
展示一系列的内容,展示的内容根据服务端下发,比如需要展示Banner
,推荐列表等。后来在二期开发时,这个界面需要添加一个小feature,服务端可能不下发Banner相关的属性,但因为之前的Adapter
中存在一些逻辑,导致如果没有下发Banner
数据的话,RecyclerView
依然会展示Banner
对应的ViewHolder
,且这个ViewHolder
为空展示。为了解决这个问题,我在Adapter
中又添加了一个新的ViewType
和对应的ViewHolder
:EmptyViewHolder
。这个ViewHolder
对应的Layout是这样的:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="0dp">
</RelativeLayout>
但是这样就会出现这篇文章中提到的问题,SwipeRefreshLayout
无法下拉刷新,简单来说,就是当RecyclerView
的第0个子View
的高度为0时,SwipeRefreshLayout
无法下拉刷新。
分析
观察SwipeRefreshLayout
无法下拉的情况,RecyclerView
下拉时出现了RippleEffect
,很明显原本需要由SwipeRefreshLayout
处理的下拉手势,被RecyclerView
消费了。至于这个手势被RecyclerView
消费的原因,根据Android的View
事件分发的原理,合理猜测是SwipeRefreshLayout
对这个事件的分发或者拦截出现了问题。
dispatchTouchEvent
SwipeRefreshLayout
继承自ViewGroup
,且自身没有重写dispatchTouchEvent
,因此问题极大可能不是出在这里。
onInterceptTouchEvent
onInterceptTouchEvent
这个方法负责事件的拦截。这个方法的基本实现是这样的:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev)
//省略部分代码
if (!isEnabled() || mReturningToStart || canChildScrollUp()
|| mRefreshing || mNestedScrollInProgress)
// Fail fast if we're not in a state where a swipe is possible
return false;
//省略部分代码
return true;
根据bug发生的现象以及代码,这个if判断非常可疑,大概率是这么一堆判断中出现了true,导致整个方法return false,没有拦截住这个事件。因此canChildScrollUp()
这个判断非常可疑。按照SwipeRefreshLayout
的运行规律,可以猜测当RecyclerView可以滑动时,canChildScrollUp()
为true,SwipeRefreshLayout
不拦截手势,RecyclerView
得以正常上下滑动;当RecyclerView处于滑动到第0个item,且继续下拉时,canChildScrollUp()
返回false,事件被SwipeRefreshLayout
拦截。
canChildScrollUp()
方法源码如下:
/**
* @return Whether it is possible for the child view of this layout to
* scroll up. Override this if the child view is a custom view.
*/
public boolean canChildScrollUp()
if (mChildScrollUpCallback != null)
return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
if (mTarget instanceof ListView)
return ListViewCompat.canScrollList((ListView) mTarget, -1);
return mTarget.canScrollVertically(-1);
这个注释挺有意思,提醒我们如果SwipeRefreshLayout
的子View是个自定义View,这个方法就得重写,告诉SwipeRefreshLayout
子View是否可以滑动。 (以前不知道这一点,所以看源码对水平还是会有提升的)
代码逻辑上,这里有三个分支,光看代码,我也不清楚走哪个分支,算了,直接debug吧。
Debug
通过debug可以看出最终走的是最后一个分支,这里的mTarget就是子View,即RecyclerView
,但是后面再想Debug RecyclerView#canScrollVertically()
时`就会发现代码行数对应不上了。这是因为国内厂商会对Android系统源代码进行定制,这里建议使用Android Studio自带的模拟器,使用原装操作系统进行Debug。
启动模拟器,重新开始Debug。
/**
* Check if this view can be scrolled vertically in a certain direction.
*
* @param direction Negative to check scrolling up, positive to check scrolling down.
* @return true if this view can be scrolled in the specified direction, false otherwise.
*/
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;
关键点在于final int offset = computeVerticalScrollOffset();
,这里offset返回了一个大于0的值,导致整个方法返回了true。
我们再去看computeVerticalScrollOffset()
发生了什么。
/**
* <p>Compute the vertical offset of the vertical scrollbar's thumb within the vertical range.
* This value is used to compute the length of the thumb within the scrollbar's track. </p>
*
* <p>The range is expressed in arbitrary units that must be the same as the units used by
* @link #computeVerticalScrollRange() and @link #computeVerticalScrollExtent().</p>
*
* <p>Default implementation returns 0.</p>
*
* <p>If you want to support scroll bars, override
* @link RecyclerView.LayoutManager#computeVerticalScrollOffset(RecyclerView.State) in your
* LayoutManager.</p>
*
* @return The vertical offset of the scrollbar's thumb
* @see RecyclerView.LayoutManager#computeVerticalScrollOffset
* (RecyclerView.State)
*/
@Override
public int computeVerticalScrollOffset()
if (mLayout == null)
return 0;
return mLayout.canScrollVertically() ? mLayout.computeVerticalScrollOffset(mState) : 0;
这里把计算竖直方向的偏移交给了layoutManager去做,我们用的是LinearLayoutManager,继续点进去看源码。经过几步跳转后,来到了ScrollbarHelper#computeScrollOffset()
,核心代码如下:
/**
* @param startChild View closest to start of the list. (top or left)
* @param endChild View closest to end of the list (bottom or right)
*/
static int computeScrollOffset(RecyclerView.State state, OrientationHelper orientation,
View startChild, View endChild, RecyclerView.LayoutManager lm,
boolean smoothScrollbarEnabled, boolean reverseLayout)
//省略部分代码...
final int minPosition = //....可见的第0个item
final int maxPosition = //...可见的最后一个item
final int itemsBefore = //...可见的第0个item的前面item的数量
//
final int laidOutArea = Math.abs(orientation.getDecoratedEnd(endChild)
- orientation.getDecoratedStart(startChild));//最后一个可见item的底部分割线到第0个可见item的顶部风格线
final int itemRange = Math.abs(lm.getPosition(startChild)
- lm.getPosition(endChild)) + 1;
//可见item的数量
final float avgSizePerRow = (float) laidOutArea / itemRange;
//每个item的平均高度
return Math.round(itemsBefore * avgSizePerRow + (orientation.getStartAfterPadding()
- orientation.getDecoratedStart(startChild)));//计算可以滑动的距离
这里总体逻辑是是这样的,首先找到屏幕中可见的最前和最后的item的position,已经这两个item之间的距离,再计算出每个item的平均高度,预估recyclerview可以滑动的距离。举个例子,当前可见3个item,position和高度分别是0->100px,1->200px,2->300p,所以这里的itemRange就是2-0+1=3,平均高度是600/3=200px,itemBefore为0,所以最终的计算结果就是可以滑动0*200px = 0px。换个场景,当前课件的是3个item,position和高度分别是1->100px,2->200px,3->300p,这里itemBefore就为1,因此计算结果为200px。
因此在本文所说的场景下,第0个item高度为0不可见,第0个可见item的position为1,因此itemBefore为1,计算结果为必定大于0。
所以回到最上面的canScrollVertically()
方法,返回值为true,会导致swipeRefreshLayout
不拦截手势,因此无法触发下拉刷新。
解决办法
设置EmptyViewHolder的高度为1px就可以了。这样第0个item也就可见了,itemBefore = 0,返回值即为0,SwipeRefreshLayout
可以拦截事件了。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="1px">
</RelativeLayout>
以上是关于SwipeRefreshLayout+RecyclerView无法下拉问题排查的主要内容,如果未能解决你的问题,请参考以下文章
如何在 SwipeRefreshLayout 中调整向下滑动的距离?
swipeRefreshLayout与webview滑动冲突