SwipeRefreshLayout+RecyclerView无法下拉问题排查

背景

在Android开发中,SwipeRefreshLayout+RecyclerView的UI模式帮助开发者很方便地提供了下拉刷新的能力,但在之前的开发中,我遇到了一个SwipeRefreshLayout无法下拉的问题。

具体的问题是,我需要通过RecyclerView展示一系列的内容,展示的内容根据服务端下发,比如需要展示Banner,推荐列表等。后来在二期开发时,这个界面需要添加一个小feature,服务端可能不下发Banner相关的属性,但因为之前的Adapter中存在一些逻辑,导致如果没有下发Banner数据的话,RecyclerView依然会展示Banner对应的ViewHolder,且这个ViewHolder为空展示。为了解决这个问题,我在Adapter中又添加了一个新的ViewType和对应的ViewHolderEmptyViewHolder。这个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>

猜你喜欢

转载自juejin.im/post/7112351178372939806