用viewDragHelper來寫Android刷新控件
先說下ViewGroup的觸摸事件的傳遞,在控件實現中需要用到:
如果是action_down,可以直接扔給內層處理,重點是action_move,如果達到某些條件就需要交給viewgroup自己處理以便下拉刷新上拉加載什么的。不過這里需要注意, 一旦截獲了action_move,除非把action_up或者cancel處理了,否則后續的事件都不會再經過onInterceptTouchEvent了,是直接走viewGroup的onTouchEvent的 。
接著是整個控件的思路:
在onInterceptTouchEvent中判斷邊界條件,如果是達到上下邊界并且是對應的滑動方向,或者本身已經是正在刷新或者正在加載的狀態,那action_move應該由viewgroup來處理,也就是直接委托給viewdraghelper來處理,否則應該由內層視圖來消費action_move
工具類,輔助類以及枚舉量
-
枚舉方向類 Direction ,用來判斷拖動的方向,STATIC(靜止),UP手指↑),DOWN(手指↓)
-
枚舉狀態類 ScrollStatus ,用來標識當前的狀態,IDLE(既沒上拉也沒下拉),DRAGGING(拖動中),REFRESHING下拉刷新中),LOADING(上拉加載中)
-
邊界判斷類 ScrollViewCompat , canSmoothDown 判斷該視圖控件還能否向下拉動 , canSmoothUp 該視圖控件還能否向上拉動 。這里和 Direction 里面的方向是一個意思,如果某個列表或者scrollView本身已經滑到頂部,那么手指從上往下拉是拉不動的,所以canSmoothDown 返回 false, canSmoothUp 正好相反
-
Range :常量,定義了拉動的最大范圍以及動畫區域高度。 MotionEventUtil :里面只有一個 getMotionEventY ,用兼容的方式獲取motionEvent的 Y 值
捕獲觸摸事件進行邊界判斷的代理類
DragDelegate :為了不至于刷新控件的代碼邏輯過于臃腫,也為了以后方便替換邊界判斷邏輯,專門將邊界判斷這部分提取出來,委托 DragDelegate 來處理。
刷新控件類
DragRefreshLayout :主要定義viewdragHelper的邏輯,比如滑動邊界,彈回等,還定義了刷新加載事件的回調
先來邊界判斷
首先發生down事件的時候其實還是要交給內層消化的,畢竟這時候還是不知道后面到底是上拉還是下拉,以及內層自己能不能滑動,當然如果后面在move的時候判斷到確實到了邊界,那么直接給內層甩一個cancel就可以了。
case MotionEvent.ACTION_DOWN:
mActivePointerId = MotionEventCompat.getPointerId(event, 0);
initY = (int) MotionEventUtil.getMotionEventY(event, mActivePointerId);
consignor.dragHelper().shouldInterceptTouchEvent(event);
break;
這里要說明一下,必須調用一下 viewDragHelper.shouldInterceptTouchEvent(event) ,因為它內部需要做某些處理,比如記錄滑動狀態,x,y值等等,如果此處沒有調用,則處理后續的move等事件的時候viewDragHelper就會失靈
case MotionEvent.ACTION_MOVE:
//如果達到邊界,則扔給內層一個cancel
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
target.dispatchTouchEvent(cancelEvent);
break;
包裝一下:
private boolean handleMotionEvent(MotionEvent event) {
// 這里要判斷是不是正在拖動中,如果已經拖動了,說明之前已經傳了一個cancel給內層了
if (!ScrollStatus.isDragging(consignor.scrollStatus())) {
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
consignor.target().dispatchTouchEvent(cancelEvent);
}
return true;
}
接著是move事件,如上所說,我們判斷是否到達邊界,如果到了則直接把move事件攔了交給viewgroup自己處理,當然先給內層甩一個cancel。如何判斷邊界條件呢?這里就要對手勢方向區別處理,如果是下拉,就應該判斷內層能不能下拉,不能就表示已經到了邊界,上拉是一樣的道理。
direction = Direction.getDirection((int) (MotionEventUtil.getMotionEvent(event,mActivePointerId) - initY));
if (direction == Direction.DOWN) {
if (consignor.isRefreshAble() ||ScrollStatus.isLoading(consignor.scrollStatus())) {
if (!ScrollStatus.isDragging(consignor.scrollStatus()) && !ScrollStatus.isRefreshing(consignor.scrollStatus()) && ScrollViewCompat.canSmoothDown(consignor.target())) {
if (ScrollStatus.isLoading(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
return handleMotionEvent(event);
}
} else {
return false;
}
} else if (direction == Direction.UP) {
if (consignor.isLoadAble() || ScrollStatus.isRefreshing(consignor.scrollStatus())) {
if (!ScrollStatus.isDragging(consignor.scrollStatus()) && !ScrollStatus.isLoading(consignor.scrollStatus()) && ScrollViewCompat.canSmoothUp(consignor.target())) {
if (ScrollStatus.isRefreshing(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
if (ScrollViewCompat.canSmoothDown(consignor.target()) || ScrollStatus.isRefreshing(consignor.scrollStatus())) {
return handleMotionEvent(event);
}
}
} else {
return false;
}
}
先拿down來說,在手指下拉的時候有這么幾種情況:
-
靜止狀態(后面就用靜止狀態來形容沒有上拉沒有下拉)并且內層已經到頂部
-
靜止狀態(后面就用靜止狀態來形容沒有上拉沒有下拉)并且內層沒到頂部
-
刷新狀態
-
加載狀態
up事件應該是差不多的,方向相反而已
這里的 isRefreshAble() 和 isLoadAble() 是刷新控件的一個功能,比如總有時候需要禁用刷新或者加載什么的,所以如果禁用了刷新那么down事件就不捕獲了直接扔給內層處理,禁用加載也是一樣的。
而后面的 ScrollStatus.isLoading(consignor.scrollStatus()) 是這么一種情況,就算禁用了下拉刷新,但是沒有禁用上拉加載,那么就會出現當前狀態是加載中的情況,這時候的Move肯定還是由刷新控件自己處理,后面的上拉操作就不贅述了。
接著是下面的
if (!ScrollStatus.isDragging(consignor.scrollStatus())
&& !ScrollStatus.isRefreshing(consignor.scrollStatus())
&& ScrollViewCompat.canSmoothDown(consignor.target())) {
if (ScrollStatus.isLoading(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
} else {
return handleMotionEvent(event);
}
如果我們沒有禁用刷新,或者當前是加載中,那么什么情況不需要刷新控件處理,而是直接扔給內層呢?
-
不是加載中,并且內層沒有到達頂部
這里如果本身就是拖動中,move就還是交給刷新控件繼續處理,然后如果是刷新中,這時候不管是上拉(取消刷新)還是下拉(再次拉一下)也還是交給刷新控件處理。所以濾過了這兩種情況,實際上只需要判斷 ScrollViewCompat.canSmoothDown(consignor.target()) ,如果當前狀態是加載中,那么不管是上拉(再次加載)還是下拉(取消加載)也是要由刷新控件來處理的
然后再來說說 up 事件,我們將要在viewDragHelper中處理 onViewReleased ,比如拉到某個位置,松手讓刷新控件自動彈回去,所以在 UP 事件出現的時候我們要做某些處理。
那么什么時候應該把 up 交給刷新控件處理呢? 當前狀態是正在被拖動的時候
case MotionEvent.ACTION_UP:
mActivePointerId = -1;
dragDirection = Direction.STATIC;
if (ScrollStatus.isDragging(consignor.scrollStatus())) {
return handleMotionEvent(event);
} else {
return false;
}
onInterceptTouchEvent 就結束了,那么 onTouchEvent 呢?就直接 consignor.dragHelper().processTouchEvent(event); 即可
這里還有一個需要完善的地方,在拉動的時候還是希望只有在縱向拉動的距離大于橫向拉動距離的時候我們才去處理(畢竟是上拉下拉控件),很簡單,用 GestureDetectorCompat 就可以了
gestureDetector = new GestureDetectorCompat(((ViewGroup) consignor).getContext(), new YScrollDetector());
class YScrollDetector extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float dx, float dy) {
return Math.abs(dx) <= Math.abs(dy);
}
}
把 handleMotionEvent 改一改
private boolean handleMotionEvent(MotionEvent event) {
if (!ScrollStatus.isDragging(consignor.scrollStatus())) {
MotionEvent cancelEvent = MotionEvent.obtain(event);
cancelEvent.setAction(MotionEvent.ACTION_CANCEL);
consignor.target().dispatchTouchEvent(cancelEvent);
}
return consignor.dragHelper().shouldInterceptTouchEvent(event) && gestureDetector.onTouchEvent(event);
}
接著是刷新控件,主要是ViewDragHelper
ViewDragHelper中我們需要用到這么幾個方法:
-
clampViewPositionVertical ,在每次拖動的時候由它計算出位置
-
onViewReleased ,放手了會調用它
-
onViewPositionChanged ,位置真正改變的時候回調
在VDH要處理的時候內層已經是達到了邊界,所以這時候就不要再被內層所影響,剩下的就是一個容器,上面的是一個刷新進度條,中間是一個高度填滿控件的內容塊(也是一個視圖),下面是一個加載進度條
控件直接用frameLayout或者直接viewgroup都可以,兩個進度條一個在上面一個在下面,那么直接通過 view.layout 設置它們的位置即可
refreshView.layout(
paddingLeft,
contentTop - refreshView.getMeasuredHeight() + paddingTop,
width - paddingRight,
contentTop + paddingTop);
loadView.layout(
paddingLeft,
contentTop + height - paddingBottom,
width - paddingRight,
contentTop + height + loadView.getMeasuredHeight() - paddingBottom);
所以只需要知道content塊的top位置,自然就能算出 refreshView 和 loadView 的位置
那么在我們拖動的時候計算位置時有沒有什么要注意的?還是說參數傳給我們要拖動的 top 我們就直接原樣返回 top ?
一般我們見過比較好的刷新控件都有這么一個特性,在上拉或者下拉的時候總是有個拉動的邊界,到那個邊界就不能繼續拉動了,要不那個進度條就會拉到很遠的位置,用戶體驗不佳。
那么就需要對 clampViewPositionVertical 被調用時傳進來的top參數進行一些過濾,即 拉動后最終的 contentTop 不能超出某個最大值,或者小于某個最小值
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE;
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return -DRAG_MAX_RANGE;
} else {
return top;
}
可是這個只是拖動的視圖是 content 的時候,如果我們拖動的是 refreshView 或者 loadView ,我們就應該在這基礎上加上或者減去它們的高度
if (child == mTarget) {
status = ScrollStatus.DRAGGING;
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE;
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return -DRAG_MAX_RANGE;
} else {
return top;
}
} else {
status = ScrollStatus.DRAGGING;
if (contentTop + dy > DRAG_MAX_RANGE) {
return DRAG_MAX_RANGE - refreshView.getMeasuredHeight();
} else if (contentTop + dy < -DRAG_MAX_RANGE) {
return getMeasuredHeight() - getPaddingBottom() - DRAG_MAX_RANGE;
} else {
return top;
}
}
當然,上面在拖動的時候不要忘了將狀態設置為 ScrollStatus.DRAGGING 畢竟 onInterceptTouchEvent 里面要判斷它
那么我們放手的時候又怎么處理呢?
-
非刷新狀態下拉刷新,但只下拉了一點點(沒有到達某個閾值),放手的時候直接彈回去
-
非刷新狀態下拉刷新,超過閾值,放手的時候開始刷新動畫
-
本身已經是刷新狀態,下拉,放手的時候還是繼續刷新動畫
-
加載狀態下拉,取消加載狀態
-
非加載狀態上拉,但沒超過閾值,放手也是彈回去
-
非加載狀態上拉,超過閾值,放手開始加載動畫
-
加載狀態上拉,放手繼續加載動畫
- 刷新狀態上拉,取消刷新狀態
-
上拉的時候直接拖到頂部,下拉的時候直接拖到底部
if (contentTop > dp2px(DRAG_MAX_DISTANCE)) { setRefreshing(true); } else if (contentTop < -dp2px(DRAG_MAX_DISTANCE)) { setLoading(true); } else if (contentTop > 0) { setRefreshing(false); } else if (contentTop == 0) { if (!ScrollViewCompat.canSmoothDown(mTarget)) { setRefreshing(false); } else if (!ScrollViewCompat.canSmoothUp(mTarget)) { setLoading(false); } } else { setLoading(false); }
這里注意一下中間的代碼段
if (contentTop == 0) {
if (!ScrollViewCompat.canSmoothDown(mTarget)) {
setRefreshing(false);
} else if (!ScrollViewCompat.canSmoothUp(mTarget)) {
setLoading(false);
}
}
為什么要分開處理呢,因為后面需要處理一下,以響應 refreshCancel 和 loadCancel
最后是 onViewPositionChanged ,這里先要根據被改變的view來做區分,因為我們現在包含三個view: content,refreshView和loadView
-
如果是content,那么它的移動我們倒是不需要關心的,VDH已經為我們做好了,但是上面的 refreshView 和下面的 loadView 必須跟著移動,我們通過 offsetTopAndBottom 方法即可
refreshView.offsetTopAndBottom(dy); loadView.offsetTopAndBottom(dy); contentTop = top;
-
如果我們拖動的是 refreshView 或者 loadView 呢?那么它們的移動VDH已經處理好了,我們只需要跟上 content 的移動即可
contentTop += dy; layoutViews();
這里我們通過重新 layoutViews 就可以移動 content
但是現在 問題 來了,我們會發現,從刷新狀態往上拉,會出現拉過頭的情況,加載也是如此,拉過頭直接就把下面的loadView給拉出來了。。。所以也必須要進行邊界判斷
- 從刷新或者靜止狀態開始拉,往上拉的時候,不能拉過 contentTop < 0
- 從加載或者靜止狀態開始拉,往下拉,不能 contentTop > 0
于是改進后應該這樣:
if (changedView == mTarget) {
if (!ScrollViewCompat.canSmoothDown(mTarget)
&& top < 0) {
contentTop = 0;
layoutViews();
} else if (ScrollViewCompat.canSmoothDown(mTarget)
&& !ScrollViewCompat.canSmoothUp(mTarget)
&& top > 0) {
contentTop = 0;
layoutViews();
} else {
refreshView.offsetTopAndBottom(dy);
loadView.offsetTopAndBottom(dy);
contentTop = top;
layoutViews();
}
} else {
if (!ScrollViewCompat.canSmoothDown(mTarget)
&& (top + refreshView.getMeasuredHeight() - getPaddingTop()) < 0) {
contentTop = 0;
layoutViews();
} else if (ScrollViewCompat.canSmoothDown(mTarget)
&& !ScrollViewCompat.canSmoothUp(mTarget)
&& (top - getMeasuredHeight() + getPaddingBottom()) > 0) {
contentTop = 0;
layoutViews();
} else {
contentTop += dy;
layoutViews();
}
}
刷新&加載
好了,VDH關鍵的幾個方法說完了,我們遺漏了什么? setRefreshing 和 setLoading !這兩個不但是由 onViewReleased 來調用,而且我們使用這個刷新控件的時候也會去調用它們,根據名字理解,就是讓刷新控件去 開啟/關閉 刷新,去 開啟/關閉 加載
那么如果我們要 開啟/關閉 刷新的時候其實是要做什么?
- 設置狀態,如果是刷新狀態就是 ScrollStatus.REFRESHING ,如果是關閉刷新狀態就統一用 ScrollStatus.IDLE (關閉加載狀態我們也用 ScrollStatus.IDLE ,這個狀態就表示既沒刷新也沒加載)
- 讓代碼動態的去移動 content , refreshView 和 loadView
那么怎么移動呢?這里要用到VDH的 smoothSlideViewTo 方法,它將某個view移動到目標位置(通過傳 top 和 left ),其實它內部是調用 scroller 的,所以這樣會觸發 computeScroll ,而且它不光是使用 scroller 來移動, 還會在每次移動后調用我們前面寫的 onViewPositionChanged
所以我們其實只用控制 content 來移動就可以了
if (refreshing) {
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAG_MAX_DISTANCE))) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.REFRESHING;
}
} else {
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.IDLE;
}
}
computeScroll :
@Override
public void computeScroll() {
animContinue = dragHelper.continueSettling(true);
if (animContinue) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
但是這里有一個問題,我們只處理了 smoothSlideViewTo 為 true 的情況,如果是 false 呢?
一般只有在VDH判斷content當前位置離要移動到的位置確實有距離,就是說確實要移動, smoothSlideViewTo 就會為 true ,反之直接返回 false 了,所以我們在 onViewReleased 中處理 contentTop == 0 的時候也是調用的 setRefreshing 和 setLoading ,這時候我們還是要去處理 false 的
那么該怎么處理?簡單,直接設置對應的狀態即可
if (refreshing) {
if (dragHelper.smoothSlideViewTo(mTarget, 0, dp2px(DRAG_MAX_DISTANCE))) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.REFRESHING;
} else {
status = ScrollStatus.REFRESHING;
}
} else {
if (dragHelper.smoothSlideViewTo(mTarget, 0, 0)) {
ViewCompat.postInvalidateOnAnimation(this);
status = ScrollStatus.IDLE;
} else {
status = ScrollStatus.IDLE;
}
}
加載部分的代碼與這個差不多,就不講了
刷新控件先暫時到這里,后面再來將動畫 drawable 加進去
來自:http://www.jianshu.com/p/6ca61ca5a857