
SwipeRefreshLayout已經推出許久了,很多App都在使用玄柏,這里對其實現方式做個分析兼呵。下拉刷新控件其實是很好的學習Android的Touch事件傳遞的用例,尤其是其中onInterceptTouchEvent()
和onTouchEvent()
方法的實現驮瞧,對于自定義ViewGroup的事件處理部分有借鑒意義氓扛。
這篇文章分析傳統(tǒng)的基于Touch事件傳遞流程的下拉刷新邏輯。(還有一個邏輯分支是NestedScroll论笔,先留個坑采郎。)
總覽
下拉刷新的實現思路并不難,如果了解過Touch事件傳遞的流程狂魔,就不難想到:
- 自定義ViewGroup包裹在需要刷新的內容View外層蒜埋。
- 在
onInterceptTouchEvent()
方法中判斷是否應當觸發(fā)下拉刷新,一般判斷條件都是內容View已經滾動到頂部最楷。 - 攔截事件并交給自身的
onTouchEvent()
方法處理整份。 - 在
onTouchEvent()
方法中處理Touch事件,包括根據刷新的狀態(tài)更新UI籽孙,觸發(fā)刷新監(jiān)聽器等烈评。
這就是最核心的下拉刷新的邏輯,下面看一下SwipeRefreshLayout是怎么實現的犯建,又有什么值得學習的地方讲冠。
Support包版本為25.1.0
onInterceptTouchEvent(MotionEvent ev)
onInterceptTouchEvent()
可以看做是下拉刷新流程的其實位置,Touch事件傳遞到SwipeRefreshLayout中胎挎,會先執(zhí)行onInterceptTouchEvent()
方法沟启,通過其返回值決定繼續(xù)向下傳遞還是讓SwipeRefreshLayout作為后續(xù)事件的消費者。
這個方法中包含如下邏輯:
如果還沒有確定需要刷新的View犹菇,找到刷新的View德迹。
-
排除5種不應該刷新的狀態(tài)。(不可用揭芍、正在復位胳搞、子View還可以下拉、正在刷新称杨、處于NestedScroll狀態(tài))
如果當前正在復位肌毅,并且收到了DOWN事件,則忽略復位狀態(tài)姑原。
如果是DOWN事件悬而,記錄初始位置和事件的pointerId(手指)。
如果是MOVE事件锭汛,如果滑動距離超過閾值笨奠,標記進入下拉刷新狀態(tài)袭蝗,將使這個方法返回true,后續(xù)事件由
onTouchEvent()
處理般婆。如果是POINTER_UP事件(非主要手指抬起)到腥,重新記錄pointerId。
如果是UP事件蔚袍,退出刷新狀態(tài)乡范,清除pointerId記錄。
源碼
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 確定刷新的View啤咽,這個View會賦值給mTarget屬性晋辆,后續(xù)判斷是否可以下拉會使用到。
ensureTarget();
final int action = MotionEventCompat.getActionMasked(ev);
int pointerIndex;
// DOWN事件時忽略復位狀態(tài)
if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
mReturningToStart = false;
}
// 5個條件滿足一個宇整,就不處理事件栈拖,讓事件向下傳遞。
if (!isEnabled() || mReturningToStart || canChildScrollUp()
|| mRefreshing || mNestedScrollInProgress) {
// Fail fast if we're not in a state where a swipe is possible
return false;
}
switch (action) {
case MotionEvent.ACTION_DOWN:
// 移動CircleView到初始值(方法名用了Target這個單詞没陡,我認為不妥。)
setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true);
mActivePointerId = ev.getPointerId(0);
mIsBeingDragged = false;
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
return false;
}
// 記錄初始按下位置
mInitialDownY = ev.getY(pointerIndex);
break;
case MotionEvent.ACTION_MOVE:
if (mActivePointerId == INVALID_POINTER) {
Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id.");
return false;
}
pointerIndex = ev.findPointerIndex(mActivePointerId);
if (pointerIndex < 0) {
return false;
}
// 當前事件的Y值索赏。
final float y = ev.getY(pointerIndex);
// 雖然方法名字叫做startDragging盼玄,但其實里面進行了判斷,是否應該攔截事件潜腻。(方法名不妥)
startDragging(y);
break;
case MotionEventCompat.ACTION_POINTER_UP:
// 重新標記激活的pointerId
onSecondaryPointerUp(ev);
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
// 停止攔截事件
mIsBeingDragged = false;
mActivePointerId = INVALID_POINTER;
break;
}
return mIsBeingDragged;
}
需要注意onSecondaryPointerUp()
方法:
private void onSecondaryPointerUp(MotionEvent ev) {
final int pointerIndex = MotionEventCompat.getActionIndex(ev);
final int pointerId = ev.getPointerId(pointerIndex);
if (pointerId == mActivePointerId) {
// This was our active pointer going up. Choose a new
// active pointer and adjust accordingly.
final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
mActivePointerId = ev.getPointerId(newPointerIndex);
}
}
這個方法的實現只支持最多兩個手指的切換埃儿,如果有第三個觸摸點,就會出現bug融涣。相似的邏輯在NestedScrollView
中也出現了童番,并且其代碼里面包含TODO:
TODO: Make this decision more intelligent.
onTouchEvent(MotionEvent ev)
這個方法的核心邏輯就是調用moveSpinner
方法和finishSpinner
方法。這兩個方法中分別對應【手指移動時拖拽CircleView移動并且更新CircleView上面箭頭的樣式】以及【Touch事件結束時判斷復位或者進入刷新狀態(tài)】威鹿。
@Override
public boolean onTouchEvent(MotionEvent ev) {
// 省略了一些代碼……
switch (action) {
case MotionEvent.ACTION_DOWN:
// 省略了一些代碼……
case MotionEvent.ACTION_MOVE: {
// 省略了一些代碼……
if (mIsBeingDragged) {
// mInitialMotionY等于(DOWN事件的坐標 + mTouchSlop)剃斧,DRAG_RATE等于0.5f
final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
// 是否需要移動CircleView
if (overscrollTop > 0) {
// 移動CircleView
moveSpinner(overscrollTop);
} else {
return false;
}
}
break;
}
case MotionEventCompat.ACTION_POINTER_DOWN: {
// 有新手指按下,標記新手指忽你。
pointerIndex = MotionEventCompat.getActionIndex(ev);
if (pointerIndex < 0) {
Log.e(LOG_TAG,
"Got ACTION_POINTER_DOWN event but have an invalid action index.");
return false;
}
mActivePointerId = ev.getPointerId(pointerIndex);
break;
}
case MotionEventCompat.ACTION_POINTER_UP:
onSecondaryPointerUp(ev);
break;
case MotionEvent.ACTION_UP: {
// 省略了一些代碼……
if (mIsBeingDragged) {
final float y = ev.getY(pointerIndex);
final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE;
mIsBeingDragged = false;
// 復位CircleView或者移動到刷新狀態(tài)的位置(getTop() == 64dp)
finishSpinner(overscrollTop);
}
mActivePointerId = INVALID_POINTER;
return false;
}
case MotionEvent.ACTION_CANCEL:
return false;
}
return true;
}
moveSpinner()
方法實現了CircleView位置的計算以及箭頭屬性的計算幼东,可以跳過。
finishSpinner()
方法判斷滑動距離是否超過了閾值科雳,超過的話調用setRefresh(boolean, boolean)
方法觸發(fā)刷新回調:
private void finishSpinner(float overscrollTop) {
if (overscrollTop > mTotalDragDistance) {
// 觸發(fā)刷新
setRefreshing(true, true /* notify */);
} else {
// cancel refresh
mRefreshing = false;
// 省略一些代碼……
}
}
setRefresh()
方法:
private void setRefreshing(boolean refreshing, final boolean notify) {
if (mRefreshing != refreshing) {
mNotify = notify;
ensureTarget();
mRefreshing = refreshing;
if (mRefreshing) {
// 移動到刷新位置根蟹。
animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
} else {
// 停止刷新時的處理,執(zhí)行CircleView的縮小動畫糟秘。
startScaleDownAnimation(mRefreshListener);
}
}
}
注意animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener);
這一行简逮,回調的邏輯在mRefreshLinstener
里面:
private Animation.AnimationListener mRefreshListener = new Animation.AnimationListener() {
// 省略一些代碼
@SuppressLint("NewApi")
@Override
public void onAnimationEnd(Animation animation) {
if (mRefreshing) {
// Make sure the progress view is fully visible
mProgress.setAlpha(MAX_ALPHA);
mProgress.start();
if (mNotify) {
// 通知回調
if (mListener != null) {
mListener.onRefresh();
}
}
mCurrentTargetOffsetTop = mCircleView.getTop();
} else {
reset();
}
}
};
當執(zhí)行mListener.onRefresh()
方法時,就是執(zhí)行我們熟悉的回調方法了尿赚。
刷新結束之后散庶,調用setRefreshing(false);
方法時蕉堰,也會執(zhí)行到上面兩個參數的setRefreshing(false, false)
方法,執(zhí)行縮小動畫督赤。
canChildScrollUp
下拉刷新邏輯中的一個關鍵判斷就是判斷子View是否已經滑動到最頂端嘁灯,SwipeRefreshLayout使用canChildScrollUp()
方法進行這個判斷:
public boolean canChildScrollUp() {
if (mChildScrollUpCallback != null) {
return mChildScrollUpCallback.canChildScrollUp(this, mTarget);
}
if (android.os.Build.VERSION.SDK_INT < 14) {
if (mTarget instanceof AbsListView) {
final AbsListView absListView = (AbsListView) mTarget;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return ViewCompat.canScrollVertically(mTarget, -1) || mTarget.getScrollY() > 0;
}
} else {
return ViewCompat.canScrollVertically(mTarget, -1);
}
}
除了SDK版本14以下對于ListView的特殊處理,都使用ViewCompat.canScrollVertically(mTarget, -1);
這個方法進行判斷躲舌。最終會執(zhí)行下面的判斷邏輯:
private boolean canScrollingViewScrollVertically(ScrollingView view, int direction) {
final int offset = view.computeVerticalScrollOffset();
final int range = view.computeVerticalScrollRange() -
view.computeVerticalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
其中的關鍵數值offset
最終還是會從View的mScrollY
屬性獲取丑婿,和getScrollY()
獲取到的是同一個值。
這里需要注意的問題是direction的認定没卸。ViewCompat.canScrollVertically(mTarget, -1);
這個方法的參數-1
以及canChildScrollUp()
的方法名羹奉,都包含了UP這個方向,但是我們判斷是否到頂了不應該是判斷【是否能向下滾動】嗎约计,為什么是相反的呢诀拭?
原因要從mScrollY
這個參數上找,mScrollY
的含義其實是View相對于內容的偏移量:

上圖中煤蚌,mScrollY
的值實際上內容坐標系中View顯示區(qū)域的偏移量耕挨。圖中的mScrollY
的符號位正。也就是我們通常所說的“上拉”對應mScrollY
的值為正值尉桩,反之負值就對應“下拉”了筒占,也就是上文提到的UP。
-1
還可以理解為使mScrollY減小的方向蜘犁,自然也就是“下拉”了翰苫。
總之,這里確實有點繞这橙。
關于Draw
SwipeRefreshLayout中奏窑,還有幾個和繪制相關的點,值得關注一下屈扎。
setWillNotDraw(boolean)
:這個方法關聯(lián)到ViewGroup的一個flag埃唯,默認情況下為true,也就是自身不需要進行繪制鹰晨,底層會根據這個flag進行優(yōu)化筑凫。需要繪制的話,需要將flag置為true并村。
ViewCompat.setChildrenDrawingOrderEnabled(ViewGroup viewGroup, boolean enable)
:通常我們自定義ViewGroup時需要將某個View在頂層繪制巍实,都是調用View.bringToFront();
方法將其移動到最頂層,但是這個方法有一個副作用哩牍,后面會提到棚潦。而ViewCompat的這個方法提供了另一種解決方案。
ViewGroup在繪制子View時膝昆,如果之前調用了setChildrenDrawingOrderEnabled()
設置為true丸边,會調用getChildDrawingOrder()
重新確定每個子View的繪制順序叠必,也就可以實現將某個View的順序放置到頂層了。SwipeRefreshLayout的實現如下:
@Override
protected int getChildDrawingOrder(int childCount, int i) {
if (mCircleViewIndex < 0) {
return i;
} else if (i == childCount - 1) {
// Draw the selected child last
return mCircleViewIndex;
} else if (i >= mCircleViewIndex) {
// Move the children after the selected child earlier one
return i + 1;
} else {
// Keep the children before the selected child the same
return i;
}
}
解釋一下妹窖,第一個參數很好理解纬朝,第二個參數是迭代位置,返回值是子View的index骄呼,這個方法的作用可以理解為:第i次應該繪制哪個子View共苛,默認實現是return i;
。也就是按照子View的順序繪制蜓萄。針對上面的實現隅茎,假設mCircleViewIndex
的值為2,childCount
的值為6嫉沽,那么會得到如下結果辟犀。

是不是很有趣?
Measure 和 Layout:Measure的過程中绸硕,對于mTarget堂竟,忽略LayoutParams參數,直接設置為填滿父控件的值玻佩。Layout過程中跃捣,只對mCircleView和mTarget兩個View進行布局。這些都是常用的行之有效的處理方法夺蛇。
總結
以上就是對SwipeRefreshLayout的分析,當然開頭提到了酣胀,只是Touch事件的邏輯分支刁赦,NestedScroll相關的內容,就留到下次啦闻镶。