改造的起因
在平時開發中用過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支持一下。