改造的起因
在平时开发中用过SwipeRefreshLayout的同学都知道,SwipeRefreshLayout的灵敏度实在是有点高,往往轻轻拉一下就会触发刷新,网上解决办法大多是下面这样:
mSwipeRefreshLayout.setDistanceToTriggerSync(200);
虽然不是很优雅,但也解决了问题。但如果仅仅只是这样,也就没有这篇文章了,我在实际开发中发现SwipeRefreshLayout还有许多令人无法忍受的缺陷,下面我们就来一一解决。
第一次改造
不知道大家平时有没有这种感受,当SwipeRefreshLayout里嵌套一个滑动视图,比如RecyclerView的时候,本来只是想把RecyclerView滑到最上面,却经常在最后触发刷新。这就是促使我改造SwipeRefreshLayout的第一个原因,下面我们就来看看如何解决这个问题。
首先创建SwipeRefreshLayout的子类,我把它起名GSwipeRefreshLayout,我们接下来的改造都会在这个子类中进行。
接着我们来尝试解决上面的那个问题,仔细想一下,往往我们刷新的误触发都是因为手指向下划动的时候幅度过大,明明看着还没到最开始(其实也只差一点了),等想要收手的时候已经来不及了,这么大的幅度就算扣除到顶部的距离也足够触发刷新了,就像下面这样:
而我们的解决思路就是将刷新限制到只在滑动视图处于顶部的时候触发,具体可以看下面的代码:
public class GSwipeRefreshLayout extends SwipeRefreshLayout {
private boolean mHasScrollingChild = false;
private ScrollingView mScrollingChild = null;
public GSwipeRefreshLayout(Context context) {
this(context, null);
}
public GSwipeRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 此时SwipeRefreshLayout会有一个CircleImageView的子View
if(getChildCount() > 1 && getChildAt(1) instanceof ScrollingView) {
mHasScrollingChild = true;
mScrollingChild = (ScrollingView) getChildAt(1);
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if(mHasScrollingChild) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
setEnabled(true); //每次按下时先开启SwipeRefreshLayout,保证正常工作
if(mScrollingChild.computeVerticalScrollOffset() != 0) {
setEnabled(false); //如果子View不处于顶部则禁用SwipeRefreshLayout
}
}
return super.dispatchTouchEvent(ev);
} else {
return super.dispatchTouchEvent(ev);
}
}
}
这里的逻辑很简单,就是在手指按下的时候,判断子视图如果不处于顶部就禁用SwipeRefreshLayout。在这个简单的改造之后,我们终于可以愉快的往下划而不怕触发刷新了:
第二次改造
在第一次改造过后没多久,很快我就发现了另一个问题,有时如果这一页末尾有一篇文章我比较感兴趣又没有显示完全,我会先把它滑上来看一下完整的标题,再回到顶部依次浏览。这就造成了一个问题,在我手指按下的时候子View是处于顶部的,但当我再次向下划的时候就有可能错误的触发刷新:
有了上次的成功经验,这次又怎么能难倒我呢?我们只需要在滑动的时候做一下判断,如果是向上划,我们就直接禁用SwipeRefreshLayout:
private float mDownPostion;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if(mHasScrollingChild) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
setEnabled(true);
mDownPostion = ev.getY();
if(mScrollingChild.computeVerticalScrollOffset() != 0) {
setEnabled(false);
}
break;
case MotionEvent.ACTION_MOVE:
if(isEnabled()) {
if (ev.getY() < mDownPostion) setEnabled(false);
}
break;
}
return super.dispatchTouchEvent(ev);
} else {
return super.dispatchTouchEvent(ev);
}
}
可是效果出来后我傻眼了,和没改之前一毛一样,可是我明明已经禁用SwipeRefreshLayout了,怎么还会触发刷新呢?带着这个问题我开始了一番Debug,这里大家要注意SwipeRefreshLayout里有一个方法moveSpinner(float overscrollTop),这个方法是用来改变CircleImageView的位置的,所以如果触发了CircleImageView的滑动效果一定是调用了这个方法,我们只需要在这里守株待兔就行了。
结果出来了,竟然是通过RecyclerView调用的这个方法。
这里又有一个概念NestedScroll,这是Android在5.0新加入的API,为了支持一些复杂的滑动效果,大家看到的上面图中标题栏隐藏的效果就是Android通过NestedScroll实现的。具体的原理大致就是子View调用父View的onNestedScroll()方法,将滑动动作传递到上层控件。这里就是RecyclerView调用了SwipeRefreshLayout的onNestedScroll()方法,我们来看一下这个方法:
@Override
public void onNestedScroll(final View target, final int dxConsumed, final int dyConsumed,
final int dxUnconsumed, final int dyUnconsumed) {
// Dispatch up to the nested parent first
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
mParentOffsetInWindow);
// This is a bit of a hack. Nested scrolling works from the bottom up, and as we are
// sometimes between two nested scrolling views, we need a way to be able to know when any
// nested scrolling parent has stopped handling events. We do that by using the
// 'offset in window 'functionality to see if we have been moved from the event.
// This is a decent indication of whether we should take over the event stream or not.
final int dy = dyUnconsumed + mParentOffsetInWindow[1];
if (dy < 0 && !canChildScrollUp()) {
mTotalUnconsumed += Math.abs(dy);
moveSpinner(mTotalUnconsumed);
}
}
罪魁祸首原来在这里,onNestedScroll()方法在最后调用了moveSpinner()触发刷新,而我们也只需要重写它,取消它在这里的调用就可以了。
// 控件disable时禁止调用moveSpinner()方法
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed) {
if(isEnabled()) {
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed);
} else {
//这里用了反射获取父类私有变量,用来调用dispatchNestedScroll(),保证NestedScroll效果不出错
try {
Field mParentOffsetInWindowField =
SwipeRefreshLayout.class.getDeclaredField("mParentOffsetInWindow");
mParentOffsetInWindowField.setAccessible(true);
int[] mParentOffsetInWindow = (int[]) mParentOffsetInWindowField.get(this);
// Dispatch up to the nested parent first
dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed,
mParentOffsetInWindow);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
效果拔群!
最终的改造
问题再次出现,这次是我发现当我想取消刷新的时候,往上划的那一下会导致RecyclerView的滑动,看起来交互非常混乱:
这里我们只需要对取消刷新的动作进行一个判断,不再向下对RecyclerView进行事件的分发就可以了:
private boolean mIsDragMode = false;
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if(mHasScrollingChild) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
setEnabled(true);
mDownPostion = ev.getY();
if(mScrollingChild.computeVerticalScrollOffset() != 0) {
setEnabled(false);
}
break;
case MotionEvent.ACTION_MOVE:
if(isEnabled()) {
if (ev.getY() < mDownPostion) setEnabled(false);
else mIsDragMode = true;
}
break;
case MotionEvent.ACTION_UP:
mIsDragMode = false;
}
return super.dispatchTouchEvent(ev);
} else {
return super.dispatchTouchEvent(ev);
}
}
// 当CircleImageView向下拖动时,停止向子View分发单击事件(即使在当前点击事件中再次向上滑动)。
// 由于停止点击事件的分发造成滑动的灵敏度降低(恢复正常,原来的灵敏度过高)
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return mIsDragMode || super.onInterceptTouchEvent(ev);
}
后记
这次的SwipeRefreshLayout也算是历经波折,不过收获也不少。大家如果想看源码的话,我这个项目GavinLi369/LoveMusic里就有。当然,如果喜欢我这个项目的话,也别忘了Star支持一下。