概述
RecyclerView不僅實現在有限窗口顯示大數據集,還支持對其中的item視圖進行Swipe(輕掃)和Drag(拖拽)操作,這可以藉助ItemTouchHelper輔助類輕鬆實現。
基本使用
關鍵代碼:
// 1.創建ItemTouchHelper.Callback,實現回調方法
ItemTouchHelper.Callback callback = new ItemTouchHelper.Callback() {
// 返回允許滑動的方向
@Override
public int getMovementFlags(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
// 返回可滑動方向,通過使用一個int,在各個bit位標記來記錄。
// 這裏drag支持上下方向,swipe支持左右方向。
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
int swipeFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
// 返回設置了標識位的複合int
return makeMovementFlags(dragFlags, swipeFlags);
}
// 允許drag的前提下,當ItemTouchHelper想要將拖動的項目從其舊位置移動到新位置時調用
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
// 獲取被拖拽item和目標item的適配器索引(適配器索引是該item對應數據集的索引,getLayoutPosition是當前佈局的位置)
int from = viewHolder.getAdapterPosition();
int to = target.getAdapterPosition();
// 交換數據集的數據
Collections.swap(data, from, to);
// 通知Adapter更新
adapter.notifyItemMoved(from, to);
// 返回true表示item移到了目標位置
return true;
}
// 允許swipe的前提下,當用戶滑動ViewHolder觸發臨界時調用
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
// 獲取滑動的item對應的適配器索引
int pos = viewHolder.getAdapterPosition();
// 從數據集移除數據
data.remove(pos);
// 通知Adapter更新
adapter.notifyItemRemoved(pos);
}
};
// 2.傳入ItemTouchHelper.Callback
ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
// 3.將touchHelper和recyclerView綁定
touchHelper.attachToRecyclerView(recyclerView);
以上代碼三個步驟就可以實現swipe和drag,效果如圖:
關鍵思考
我們知道RecyclerView作爲ViewGroup,有自己的滑動事件處理,那麼ItemTouchHelper是如何進行swipe和drag,而不產生衝突。
ItemTouchHelper如何通過attachToRecyclerView方法附加RecyclerView,就能將觸摸事件託管到自己身上執行。
ItemTouchHelper.Callback接口中的onMove和onSwiped是在什麼時機回調。
我們注意到上面圖示中,Drag操作拖拽item到達邊界時,RecyclerView會跟着滾動起來,這是如何調度的。
帶着這些問題進入源碼,看看ItemTouchHelper大致實現機制,就能知道答案。
源碼探究
文中源碼基於 ‘androidx.recyclerview:recyclerview:1.1.0’
ItemTouchHelper綁定
首先看attachToRecyclerView方法:
[ItemTouchHelper#attachToRecyclerView]
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
if (mRecyclerView == recyclerView) {
return; // nothing to do
}
// 若綁定過其他RecyclerView,則與舊的解除綁定和清理數據
if (mRecyclerView != null) {
destroyCallbacks();
}
mRecyclerView = recyclerView;
if (recyclerView != null) {
final Resources resources = recyclerView.getResources();
mSwipeEscapeVelocity = resources
.getDimension(R.dimen.item_touch_helper_swipe_escape_velocity);
mMaxSwipeVelocity = resources
.getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity);
// 設置回調
setupCallbacks();
}
}
關鍵在setupCallbacks方法中:
[ItemTouchHelper#setupCallbacks]
private void setupCallbacks() {
ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
mSlop = vc.getScaledTouchSlop();
// 添加到mRecyclerView的mItemDecorations集合中
mRecyclerView.addItemDecoration(this);
// 添加到mRecyclerView的mOnItemTouchListeners集合中
mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
// 添加到mRecyclerView的mOnChildAttachStateListeners集合中
mRecyclerView.addOnChildAttachStateChangeListener(this);
// 創建GestureDetector
startGestureDetection();
}
該方法中進行各類回調監聽註冊,這些回調監聽是實現swipe和drag的關鍵。
ItemTouchHelper繼承ItemDecoration,ItemDecoration作用是裝飾item,通常用來繪製分割線。ItemTouchHelper藉助其實現item跟隨手指移動。
mOnItemTouchListener註冊給RecyclerView後,RecyclerView會將事件回調給它,ItemTouchHelper從而能夠攔截事件派發自行處理。
ItemTouchHelper實現OnChildAttachStateChangeListener接口,在該接口的onChildViewDetachedFromWindow方法中處理視圖detached時進行釋放動作或結束動畫、清理視圖引用。
startGestureDetection方法中會創建GestureDetector,用於監聽觸摸事件。當觸發onLongPress長按時,判斷是否開始drag。
RecyclerView觸摸事件託管
接下來看看RecyclerView如何把觸摸事件託管給ItemTouchHelper。
onInterceptTouchEvent
[RecyclerView#onInterceptTouchEvent]
public boolean onInterceptTouchEvent(MotionEvent e) {
// ···
mInterceptingOnItemTouchListener = null;
// 判斷是否有OnItemTouchListener攔截事件
if (findInterceptingOnItemTouchListener(e)) {
// 若有攔截則取消滾動
cancelScroll();
return true;
}
// RecyclerView的onInterceptTouchEvent邏輯 ···
}
private boolean findInterceptingOnItemTouchListener(MotionEvent e) {
int action = e.getAction();
final int listenerCount = mOnItemTouchListeners.size();
// 依次將事件派發給mOnItemTouchListeners保存的listener
for (int i = 0; i < listenerCount; i++) {
final OnItemTouchListener listener = mOnItemTouchListeners.get(i);
if (listener.onInterceptTouchEvent(this, e) && action != MotionEvent.ACTION_CANCEL) {
// 若有listener攔截事件並且當前事件不是CANCEL,則用mInterceptingOnItemTouchListener保存該listener,結束遍歷
mInterceptingOnItemTouchListener = listener;
return true;
}
}
return false;
}
RecyclerView在onInterceptTouchEvent方法中處理RecyclerView自身事件攔截邏輯前,會先派發給OnItemTouchListener集合,若有OnItemTouchListener處理則RecyclerView自身不再處理。
onTouchEvent
[RecyclerView#onTouchEvent]
public boolean onTouchEvent(MotionEvent e) {
// ···
// 判斷是否有OnItemTouchListener消費事件
if (dispatchToOnItemTouchListeners(e)) {
// 若有消費事件則取消滾動
cancelScroll();
return true;
}
// RecyclerView的onTouchEvent邏輯 ···
}
private boolean dispatchToOnItemTouchListeners(MotionEvent e) {
if (mInterceptingOnItemTouchListener == null) {
// 若在onInterceptTouchEvent時沒有OnItemTouchListener攔截事件,那麼這裏
// 還會將事件派發給OnItemTouchListener,但是會過濾掉DOWN事件,避免重複派發。
if (e.getAction() == MotionEvent.ACTION_DOWN) {
return false;
}
return findInterceptingOnItemTouchListener(e);
} else {
// 若有攔截事件的OnItemTouchListener,則直接交給它的onTouchEvent方法
mInterceptingOnItemTouchListener.onTouchEvent(this, e);
final int action = e.getAction();
if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
// 若是事件序列結束,則清空mInterceptingOnItemTouchListener
mInterceptingOnItemTouchListener = null;
}
return true;
}
}
RecyclerView在onTouchEvent中處理自身的邏輯前,會先將事件派發給OnItemTouchListener,若有消費事件,則RecyclerView自身不再處理。
requestDisallowInterceptTouchEvent
RecyclerView重寫了requestDisallowInterceptTouchEvent方法,在其中也會回調OnItemTouchListener:
[RecyclerView#requestDisallowInterceptTouchEvent]
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
final int listenerCount = mOnItemTouchListeners.size();
// 依次調用OnItemTouchListeners集合中listener
for (int i = 0; i < listenerCount; i++) {
final OnItemTouchListener listener = mOnItemTouchListeners.get(i);
// 在ItemTouchHelper的listener中,若傳入不希望攔截事件,那麼ItemTouchHelper會釋放移動
listener.onRequestDisallowInterceptTouchEvent(disallowIntercept);
}
super.requestDisallowInterceptTouchEvent(disallowIntercept);
}
ItemTouchHelper攔截事件處理
RecyclerView在收到觸摸事件時,會優先將事件交給OnItemTouchListener,若有事件被消費,則RecyclerView自身不再消費。ItemTouchHelper便是通過OnItemTouchListener來接收事件,觸發SWIPE或DRAG。
onInterceptTouchEvent
看看ItemTouchHelper的mOnItemTouchListener實現的對應事件攔截方法。
[OnItemTouchListener#onInterceptTouchEvent]
public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView,
@NonNull MotionEvent event) {
// GestureDetector監聽輸入的事件
mGestureDetector.onTouchEvent(event);
if (DEBUG) {
Log.d(TAG, "intercept: x:" + event.getX() + ",y:" + event.getY() + ", " + event);
}
final int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
// 若該event是一個事件序列的開始,則記錄觸摸點ID和初始座標位置
mActivePointerId = event.getPointerId(0);
mInitialTouchX = event.getX();
mInitialTouchY = event.getY();
obtainVelocityTracker();
// mSelected成員記錄當前選中的ViewHolder,默認爲null
if (mSelected == null) {
// 從mRecoverAnimations集合中根據event位置查找對應item的回覆動畫(回覆動畫是手指釋放時,view自動移動到指定位置的動畫)
final RecoverAnimation animation = findAnimation(event);
if (animation != null) {
// animation的mX、mY記錄當前view的偏移位置
mInitialTouchX -= animation.mX;
mInitialTouchY -= animation.mY;
// 結束動畫
endRecoverAnimation(animation.mViewHolder, true);
// mPendingCleanup緩存detached後待清除狀態的view
if (mPendingCleanup.remove(animation.mViewHolder.itemView)) {
// 恢復view的Elevation屬性,將TranslationX、TranslationY重置爲0
mCallback.clearView(mRecyclerView, animation.mViewHolder);
}
// 將該item重新作爲選中的ViewHolder
select(animation.mViewHolder, animation.mActionState);
// 更新mDx、mDy(mDx、mDy記錄已經滑動的偏移量)
updateDxDy(event, mSelectedFlags, 0);
}
}
} else if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
// 若該event是一個事件序列的結束,則清空觸摸點ID和釋放選中ViewHolder
mActivePointerId = ACTIVE_POINTER_ID_NONE;
select(null, ACTION_STATE_IDLE);
} else if (mActivePointerId != ACTIVE_POINTER_ID_NONE) {
// in a non scroll orientation, if distance change is above threshold, we
// can select the item
// 這個case中的event就是事件序列的中間事件,若觸摸點ID存在(前面ACTION_DOWN時會保存)
// 獲取觸摸點索引
final int index = event.findPointerIndex(mActivePointerId);
if (DEBUG) {
Log.d(TAG, "pointer index " + index);
}
if (index >= 0) {
// 檢查是否符合swipe觸發條件,若符合則會調用select方法進行選中處理
checkSelectForSwipe(action, event, index);
}
}
if (mVelocityTracker != null) {
// 監聽event,用於加速度計算
mVelocityTracker.addMovement(event);
}
// 若有選中的ViewHolder,則返回true表示攔截
return mSelected != null;
}
該方法主要邏輯就是在ACTION_DOWN時記錄初始觸摸位置,ACTION_MOVE、ACTION_POINTER_DOWN、ACTION_POINTER_UP時判斷是否符合swipe觸發條件,ACTION_UP、ACTION_CANCEL時釋放。
其中調用checkSelectForSwipe方法檢查swipe條件,是觸發swipe的關鍵方法。還有注意到有多個地方會調用select方法,該方法也是關鍵方法,會處理item選中和釋放的操作。
onTouchEvent
[OnItemTouchListener#onTouchEvent]
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
// 手勢監聽
mGestureDetector.onTouchEvent(event);
if (DEBUG) {
Log.d(TAG,
"on touch: x:" + mInitialTouchX + ",y:" + mInitialTouchY + ", :" + event);
}
// 加速度監聽
if (mVelocityTracker != null) {
mVelocityTracker.addMovement(event);
}
if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
return;
}
final int action = event.getActionMasked();
final int activePointerIndex = event.findPointerIndex(mActivePointerId);
if (activePointerIndex >= 0) {
// 檢查是否觸發swipe
checkSelectForSwipe(action, event, activePointerIndex);
}
ViewHolder viewHolder = mSelected;
if (viewHolder == null) {
// 若不滿足swipe條件則返回
return;
}
// 執行到這裏,說明有在swipe或drag的item
switch (action) {
case MotionEvent.ACTION_MOVE: {
// Find the index of the active pointer and fetch its position
if (activePointerIndex >= 0) {
// 更新滑動偏移量
updateDxDy(event, mSelectedFlags, activePointerIndex);
// 如果當前處於drag狀態,則會判斷是否達到和某個item交換的條件,觸發onMove回調
moveIfNecessary(viewHolder);
// mScrollRunnable用於處理當用戶拖動item超出邊緣時觸發LayoutManager滾動
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
// 觸發RecyclerView重繪
mRecyclerView.invalidate();
}
break;
}
case MotionEvent.ACTION_CANCEL:
// 若是CANCEL事件(手指劃出view範圍),則清除加速度計算
if (mVelocityTracker != null) {
mVelocityTracker.clear();
}
// fall through
case MotionEvent.ACTION_UP:
// ACTION_CANCEL和ACTION_UP都釋放選中
select(null, ACTION_STATE_IDLE);
mActivePointerId = ACTIVE_POINTER_ID_NONE;
break;
case MotionEvent.ACTION_POINTER_UP: {
// 多指觸摸情況下一個手指擡起,更新觸摸點ID和滑動偏移量
final int pointerIndex = event.getActionIndex();
final int pointerId = event.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 = event.getPointerId(newPointerIndex);
updateDxDy(event, mSelectedFlags, pointerIndex);
}
break;
}
}
}
該方法中也會通過checkSelectForSwipe方法判斷是否觸發swipe。注意ACTION_MOVE case中是手指拖動滑動的關鍵代碼。
可以看到ItemTouchHelper也會將收到的事件傳給GestureDetector和VelocityTracker。其中GestureDetector用於監聽長按事件,VelocityTracker用於計算加速度,當手指全部擡起時判斷是否swipe移出item。
SWIPE和DRAG觸發判定
動作狀態
[ItemTouchHelper]
// 空閒狀態,當前沒有用戶事件或事件尚未觸發swipe或drag
public static final int ACTION_STATE_IDLE = 0;
// 目前正在swipe視圖
public static final int ACTION_STATE_SWIPE = 1;
// 目前正在drag視圖
public static final int ACTION_STATE_DRAG = 2;
private int mActionState = ACTION_STATE_IDLE;
ItemTouchHelper的mActionState成員用於記錄當前狀態。
SWIPE和DRAG不能同時觸發,接下來分別看下兩種操作的觸發條件。
SWIPE觸發
ItemTouchHelper接收到開始滑動事件時調用checkSelectForSwipe檢查SWIPE:
[ItemTouchHelper#checkSelectForSwipe]
void checkSelectForSwipe(int action, MotionEvent motionEvent, int pointerIndex) {
if (mSelected != null || action != MotionEvent.ACTION_MOVE
|| mActionState == ACTION_STATE_DRAG || !mCallback.isItemViewSwipeEnabled()) {
// mCallback即我們創建的ItemTouchHelper.Callback,可重寫isItemViewSwipeEnabled方法禁用Swipe。
// 若已有選中的ViewHolder或當前事件非ACTION_MOVE或當前已處於DRAG中或禁用了Swipe,則返回。
return;
}
if (mRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) {
// RecyclerView自身已在滾動中,則返回
return;
}
// 查找可滑動的ViewHolder
final ViewHolder vh = findSwipedView(motionEvent);
if (vh == null) {
// 沒有符合的ViewHolder則返回
return;
}
// 獲取支持滑動的方向,將調用ItemTouchHelper.Callback的getMovementFlags回調方法返回我們設置的方向
final int movementFlags = mCallback.getAbsoluteMovementFlags(mRecyclerView, vh);
// 取出swipe對應標識位
final int swipeFlags = (movementFlags & ACTION_MODE_SWIPE_MASK)
>> (DIRECTION_FLAG_COUNT * ACTION_STATE_SWIPE);
// 判斷是否有支持swipe的方向
if (swipeFlags == 0) {
return;
}
// mDx and mDy are only set in allowed directions. We use custom x/y here instead of
// updateDxDy to avoid swiping if user moves more in the other direction
final float x = motionEvent.getX(pointerIndex);
final float y = motionEvent.getY(pointerIndex);
// Calculate the distance moved
// 計算滑動距離
final float dx = x - mInitialTouchX;
final float dy = y - mInitialTouchY;
// swipe target is chose w/o applying flags so it does not really check if swiping in that
// direction is allowed. This why here, we use mDx mDy to check slope value again.
// 取絕對值
final float absDx = Math.abs(dx);
final float absDy = Math.abs(dy);
// 判斷距離是否達到最小滑動距離
if (absDx < mSlop && absDy < mSlop) {
return;
}
// 判斷滑動方向,水平和垂直滑動偏移,哪個方向大,就屬於哪個方向
if (absDx > absDy) {
// 手指向左劃,判斷是否支持左滑
if (dx < 0 && (swipeFlags & LEFT) == 0) {
return;
}
// 判斷是否支持右劃
if (dx > 0 && (swipeFlags & RIGHT) == 0) {
return;
}
} else {
// 手指向上劃,判斷是否支持上滑
if (dy < 0 && (swipeFlags & UP) == 0) {
return;
}
// 判斷是否支持下滑
if (dy > 0 && (swipeFlags & DOWN) == 0) {
return;
}
}
// 執行到這裏說明找到ViewHolder且滿足SWIPE條件。
// 新開始SWIPE,重置變量
mDx = mDy = 0f;
mActivePointerId = motionEvent.getPointerId(0);
// 傳入ViewHolder和SWIPE對應狀態
select(vh, ACTION_STATE_SWIPE);
}
該方法中提供findSwipedView方法查找一個ViewHolder進行SWIPE,看看這個方法:
[ItemTouchHelper#findSwipedView]
private ViewHolder findSwipedView(MotionEvent motionEvent) {
// 獲取LayoutManager
final RecyclerView.LayoutManager lm = mRecyclerView.getLayoutManager();
if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
return null;
}
final int pointerIndex = motionEvent.findPointerIndex(mActivePointerId);
// 計算滑動偏移量
final float dx = motionEvent.getX(pointerIndex) - mInitialTouchX;
final float dy = motionEvent.getY(pointerIndex) - mInitialTouchY;
final float absDx = Math.abs(dx);
final float absDy = Math.abs(dy);
// 判斷滑動偏移量是否達到最小滑動距離
if (absDx < mSlop && absDy < mSlop) {
return null;
}
if (absDx > absDy && lm.canScrollHorizontally()) {
// 若偏向水平滑動且當前LayoutManager也可水平滑動,爲避免衝突,則不能SWIPE
// (例如設置了水平排布的LinearLayoutManager,則不能進行水平方向的SWIPE操作)
return null;
} else if (absDy > absDx && lm.canScrollVertically()) {
// 同上,若垂直滑動且LayoutManager也可垂直滑動,不能SWIPE
return null;
}
// 根據event位置查找view
View child = findChildView(motionEvent);
if (child == null) {
return null;
}
// 返回view對應的ViewHolder
return mRecyclerView.getChildViewHolder(child);
}
接着看findChildView方法:
[ItemTouchHelper#findChildView]
View findChildView(MotionEvent event) {
// first check elevated views, if none, then call RV
final float x = event.getX();
final float y = event.getY();
if (mSelected != null) {
final View selectedView = mSelected.itemView;
// 若存在選中的ViewHolder,則判斷觸摸點位置是否落於該view範圍中
if (hitTest(selectedView, x, y, mSelectedStartX + mDx, mSelectedStartY + mDy)) {
return selectedView;
}
}
for (int i = mRecoverAnimations.size() - 1; i >= 0; i--) {
// 若存在回覆動畫,依次判斷觸摸點位置是否落於動畫執行的view範圍中
final RecoverAnimation anim = mRecoverAnimations.get(i);
final View view = anim.mViewHolder.itemView;
if (hitTest(view, x, y, anim.mX, anim.mY)) {
return view;
}
}
// 從上往下遍歷RecyclerView的子view,獲取觸摸點位置落於的view
return mRecyclerView.findChildViewUnder(x, y);
}
簡單總結觸發SWIPE的條件:首先計算滑動距離和滑動方向,需要滿足最小滑動距離且不能和LayoutManager的滑動方向衝突,根據觸摸點位置獲取對應的view的ViewHolder。接着判斷我們通過ItemTouchHelper.Callback設置的標識位,是否允許swipe和當前方向swipe。若都滿足,則調用select方法,傳入ViewHolder和ACTION_STATE_SWIPE,進行選中判斷操作。
DRAG觸發
DRAG是在長按時纔會觸發,ItemTouchHelper通過GestureDetector監聽MotionEvent,長按時觸發onLongPress回調:
[ItemTouchHelperGestureListener#onLongPress]
public void onLongPress(MotionEvent e) {
if (!mShouldReactToLongPress) {
return;
}
// 根據觸摸點位置獲取對應的view
View child = findChildView(e);
if (child != null) {
// 獲取view對應的ViewHolder
ViewHolder vh = mRecyclerView.getChildViewHolder(child);
if (vh != null) {
// 判斷是否支持drag,將觸發ItemTouchHelper.Callback的getMovementFlags,
// 若有設置drag方向則會返回true
if (!mCallback.hasDragFlag(mRecyclerView, vh)) {
return;
}
int pointerId = e.getPointerId(0);
// Long press is deferred.
// Check w/ active pointer id to avoid selecting after motion
// event is canceled.
if (pointerId == mActivePointerId) {
final int index = e.findPointerIndex(mActivePointerId);
final float x = e.getX(index);
final float y = e.getY(index);
// 保存初始觸摸位置座標
mInitialTouchX = x;
mInitialTouchY = y;
mDx = mDy = 0f;
if (DEBUG) {
Log.d(TAG,
"onlong press: x:" + mInitialTouchX + ",y:" + mInitialTouchY);
}
// 判斷是否允許drag,默認返回true
if (mCallback.isLongPressDragEnabled()) {
// 進行選中操作
select(vh, ACTION_STATE_DRAG);
}
}
}
}
}
可以看到在長按回調中,判斷若支持drag,則也調用select方法,傳入長按的ViewHolder和ACTION_STATE_DRAG。
select選中和釋放
在前文中看到,當觸發SWIPE或DRAG時,和ACTION_CANCEL、ACTION_UP時,均會調用select。
時機 | 參數selected | 參數actionState |
---|---|---|
觸發SWIPE | 按住的ViewHolder | ACTION_STATE_SWIPE |
觸發DRAG | 按住的ViewHolder | ACTION_STATE_DRAG |
擡起釋放 | null | ACTION_STATE_IDLE |
select方法中包含選中和釋放的邏輯,先看選中部分:
[ItemTouchHelper#select]
void select(@Nullable ViewHolder selected, int actionState) {
if (selected == mSelected && actionState == mActionState) {
// 避免重複調用
return;
}
mDragScrollStartTimeInMs = Long.MIN_VALUE;
final int prevActionState = mActionState;
// prevent duplicate animations
endRecoverAnimation(selected, true);
mActionState = actionState;
if (actionState == ACTION_STATE_DRAG) {
// 若是觸發DRAG
if (selected == null) {
throw new IllegalArgumentException("Must pass a ViewHolder when dragging");
}
// we remove after animation is complete. this means we only elevate the last drag
// child but that should perform good enough as it is very hard to start dragging a
// new child before the previous one settles.
// 保存選中的view的引用
mOverdrawChild = selected.itemView;
// 如果是小於21的版本,則會設置ViewGroup採用自定義遍歷child規則
addChildDrawingOrderCallback();
}
int actionStateMask = (1 << (DIRECTION_FLAG_COUNT + DIRECTION_FLAG_COUNT * actionState))
- 1;
boolean preventLayout = false;
// 如果之前有選中的ViewHolder,則要對其釋放
if (mSelected != null) {
// 省略釋放的邏輯 ···
}
// 如果當前是釋放,則selected爲null,否則爲將選中的ViewHolder
if (selected != null) {
mSelectedFlags =
(mCallback.getAbsoluteMovementFlags(mRecyclerView, selected) & actionStateMask)
>> (mActionState * DIRECTION_FLAG_COUNT);
// 記錄將選中view的左上角
mSelectedStartX = selected.itemView.getLeft();
mSelectedStartY = selected.itemView.getTop();
// mSelected賦值爲將選中的ViewHolder
mSelected = selected;
if (actionState == ACTION_STATE_DRAG) {
// 若是觸發DRAG,則給用戶一個觸覺反饋
mSelected.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
}
final ViewParent rvParent = mRecyclerView.getParent();
if (rvParent != null) {
// 選中時請求父佈局不攔截事件,釋放時反之
rvParent.requestDisallowInterceptTouchEvent(mSelected != null);
}
if (!preventLayout) {
// 使RecyclerView在下一次佈局時運行SimpleAnimation
mRecyclerView.getLayoutManager().requestSimpleAnimationsInNextLayout();
}
// 回調給ItemTouchHelper.Callback的onSelectedChanged
mCallback.onSelectedChanged(mSelected, mActionState);
// 觸發RecyclerView重繪
mRecyclerView.invalidate();
}
可以看出當觸發swipe或drag時,主要邏輯是保存選中view的左上角座標和ViewHolder引用。
如果是drag的話,則還會額外保存選中的view的引用和設置ViewGroup遍歷child的自定義順序(API<21) ,這樣做的目的是爲了在拖動view時,使這個view保持在其他view上面。我們知道ViewGroup在繪製child時,默認是按照mChildren數組的順序遍歷,爲了使指定的child位於上層,在API<21可以通過設置自定義遍歷規則,讓指定view在最後繪製。在API>=21可以通過設置elevation使之位於上層。
接着看釋放時的邏輯:
[ItemTouchHelper#select]
void select(@Nullable ViewHolder selected, int actionState) {
if (selected == mSelected && actionState == mActionState) {
return;
}
mDragScrollStartTimeInMs = Long.MIN_VALUE;
final int prevActionState = mActionState;
// prevent duplicate animations
endRecoverAnimation(selected, true);
mActionState = actionState;
// 省略ACTION_STATE_DRAG部分 ···
int actionStateMask = (1 << (DIRECTION_FLAG_COUNT + DIRECTION_FLAG_COUNT * actionState))
- 1;
boolean preventLayout = false;
// 如果之前有選中的ViewHolder,則要對其釋放
if (mSelected != null) {
final ViewHolder prevSelected = mSelected;
// 判斷之前選中view是否還依附於父佈局
if (prevSelected.itemView.getParent() != null) {
// 若之前不是drag操作,則獲取滑動方向。
// 調用Callback.getMovementFlags獲取我們設置的方向,然後會判斷滑動加速度
// 是否超過Callback.getSwipeEscapeVelocity設置的閾值或滑動距離是否超出
// Callback.getSwipeThreshold設置的閾值。若超過,則view將被滑走,swipeDir是滑走的那個方向。
// 否則swipeDir是0。
final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0
: swipeIfNecessary(prevSelected);
releaseVelocityTracker();
// find where we should animate to
final float targetTranslateX, targetTranslateY;
int animationType;
// 根據滑動方向計算偏移距離
switch (swipeDir) {
case LEFT:
case RIGHT:
case START:
case END:
// 水平方向,Y軸不變,X軸上view要移動到RecyclerView邊界外
targetTranslateY = 0;
targetTranslateX = Math.signum(mDx) * mRecyclerView.getWidth();
break;
case UP:
case DOWN:
targetTranslateX = 0;
targetTranslateY = Math.signum(mDy) * mRecyclerView.getHeight();
break;
default:
targetTranslateX = 0;
targetTranslateY = 0;
}
// 記錄動畫類型
if (prevActionState == ACTION_STATE_DRAG) {
animationType = ANIMATION_TYPE_DRAG;
} else if (swipeDir > 0) {
// ItemTouchHelper將完全滑走視爲SWIPE成功
animationType = ANIMATION_TYPE_SWIPE_SUCCESS;
} else {
// 回到原始位置視爲SWIPE取消
animationType = ANIMATION_TYPE_SWIPE_CANCEL;
}
// 計算選中view當前的X、Y軸偏移量,保存在mTmpPosition數組中
getSelectedDxDy(mTmpPosition);
final float currentTranslateX = mTmpPosition[0];
final float currentTranslateY = mTmpPosition[1];
// 創建RecoverAnimation,內部封裝屬性動畫操作。將view從currentTranslate移動到targetTranslate。
final RecoverAnimation rv = new RecoverAnimation(prevSelected, animationType,
prevActionState, currentTranslateX, currentTranslateY,
targetTranslateX, targetTranslateY) {
@Override
public void onAnimationEnd(Animator animation) {
// 動畫完成
super.onAnimationEnd(animation);
// 若動畫期間,用戶又觸摸該view,mOverridden會標記爲true
if (this.mOverridden) {
return;
}
if (swipeDir <= 0) {
// this is a drag or failed swipe. recover immediately
mCallback.clearView(mRecyclerView, prevSelected);
// full cleanup will happen on onDrawOver
} else {
// 滑出動畫結束
// wait until remove animation is complete.
// 將view保存進mPendingCleanup集合待後續清除
mPendingCleanup.add(prevSelected.itemView);
mIsPendingCleanup = true;
if (swipeDir > 0) {
// 針對swipe滑走view情況
// Animation might be ended by other animators during a layout.
// We defer callback to avoid editing adapter during a layout.
// 發送主線程,待沒有動畫執行時,回調Callback.onSwiped。
// 在該回調中,我們將對應的item從適配器數據集中移除。
postDispatchSwipe(this, swipeDir);
}
}
// removed from the list after it is drawn for the last time
if (mOverdrawChild == prevSelected.itemView) {
// 針對drag情況,觸發drag時mOverdrawChild賦值爲選中view,
// 這裏需要清理引用和取消ViewGroup自定義遍歷child規則。
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
}
}
};
final long duration = mCallback.getAnimationDuration(mRecyclerView, animationType,
targetTranslateX - currentTranslateX, targetTranslateY - currentTranslateY);
rv.setDuration(duration);
// 將動畫保存進mRecoverAnimations集合
mRecoverAnimations.add(rv);
// 啓動動畫
rv.start();
// preventLayout標記爲true,RecyclerView將不執行SimpleAnimation
preventLayout = true;
} else {
removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
mCallback.clearView(mRecyclerView, prevSelected);
}
// mSelected置爲null
mSelected = null;
}
if (selected != null) {
// 省略選中時的邏輯 ···
}
final ViewParent rvParent = mRecyclerView.getParent();
if (rvParent != null) {
// 選中時請求父佈局不攔截事件,釋放時反之
rvParent.requestDisallowInterceptTouchEvent(mSelected != null);
}
if (!preventLayout) {
// 使RecyclerView在下一次佈局時運行SimpleAnimation
mRecyclerView.getLayoutManager().requestSimpleAnimationsInNextLayout();
}
// 回調給ItemTouchHelper.Callback的onSelectedChanged
mCallback.onSelectedChanged(mSelected, mActionState);
// 觸發RecyclerView重繪
mRecyclerView.invalidate();
}
可以看出釋放時,會判斷swipe還是drag和是否將view滑走,計算translate創建RecoverAnimation執行屬性動畫。動畫完成後,若是swipe滑走,則發送到主線程待沒有任何動畫時回調Callback.onSwiped。若是drag,則將之前設置的ViewGroup自定義遍歷child規則取消。
SWIPE滑動和DRAG拖動
當select選中ViewHolder和onTouchEvent處理ACTION_MOVE時都會觸發RecyclerView.invalidate重繪。
RecyclerView重寫了draw和onDraw方法,看看這兩個方法:
[RecyclerView#onDraw、RecyclerView#draw]
public void onDraw(Canvas c) {
super.onDraw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}
public void draw(Canvas c) {
super.draw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
// ···
}
ItemTouchHelper繼承ItemDecoration,在和RecyclerView綁定時添加進mItemDecorations集合,因此當重繪時,會先後回調ItemTouchHelper的onDraw和onDrawOver方法。
先進入onDraw方法:
[ItemTouchHelper#onDraw]
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
// we don't know if RV changed something so we should invalidate this index.
mOverdrawChildPosition = -1;
float dx = 0, dy = 0;
if (mSelected != null) {
// 計算偏移量
getSelectedDxDy(mTmpPosition);
dx = mTmpPosition[0];
dy = mTmpPosition[1];
}
// 回調Callback的onDraw方法
mCallback.onDraw(c, parent, mSelected,
mRecoverAnimations, mActionState, dx, dy);
}
[Callback#onDraw]
void onDraw(Canvas c, RecyclerView parent, ViewHolder selected,
List<ItemTouchHelper.RecoverAnimation> recoverAnimationList,
int actionState, float dX, float dY) {
final int recoverAnimSize = recoverAnimationList.size();
for (int i = 0; i < recoverAnimSize; i++) {
// 若存在回覆動畫,則更新動畫中的view的偏移量
final ItemTouchHelper.RecoverAnimation anim = recoverAnimationList.get(i);
anim.update();
final int count = c.save();
onChildDraw(c, parent, anim.mViewHolder, anim.mX, anim.mY, anim.mActionState,
false);
c.restoreToCount(count);
}
if (selected != null) {
// 保存畫布當前狀態
final int count = c.save();
// 將調用ItemTouchUIUtilImpl的onDraw方法
onChildDraw(c, parent, selected, dX, dY, actionState, true);
// 恢復畫布到原來狀態
c.restoreToCount(count);
}
}
關鍵邏輯在ItemTouchUIUtilImpl的onDraw方法中:
[ItemTouchUIUtilImpl#onDraw]
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
// 當API>=21時,計算最大的elevation值設置給view,使它位於最上層
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}
// 更新view的偏移量
view.setTranslationX(dX);
view.setTranslationY(dY);
}
可以看出swipe和drag的滑動和拖動,是通過ItemTouchHelper監聽RecyclerView重繪,不斷更新view的位移座標來實現的。
onDrawOver方法中的邏輯和onDraw類似,區別是多了對mRecoverAnimations的清理判斷工作,會回調ItemTouchUIUtilImpl的onDrawOver方法,但是該方法是空實現。
DRAG觸發交換和滾動
前文分析過OnItemTouchListener.onTouchEvent中在ACTION_MOVE會判斷是否觸發RecyclerView滾動:
[OnItemTouchListener#onTouchEvent]
public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
mGestureDetector.onTouchEvent(event);
// ···
switch (action) {
case MotionEvent.ACTION_MOVE: {
// Find the index of the active pointer and fetch its position
if (activePointerIndex >= 0) {
// 更新滑動偏移量
updateDxDy(event, mSelectedFlags, activePointerIndex);
// 如果當前處於drag狀態,則會判斷是否達到和某個item交換的條件,觸發onMove回調
moveIfNecessary(viewHolder);
// mScrollRunnable用於處理當用戶拖動item超出邊緣時觸發LayoutManager滾動
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
// 觸發RecyclerView重繪
mRecyclerView.invalidate();
}
break;
}
// ···
}
// ···
}
進入moveIfNecessary方法:
[ItemTouchHelper#moveIfNecessary]
void moveIfNecessary(ViewHolder viewHolder) {
if (mRecyclerView.isLayoutRequested()) {
return;
}
if (mActionState != ACTION_STATE_DRAG) {
return;
}
final float threshold = mCallback.getMoveThreshold(viewHolder);
final int x = (int) (mSelectedStartX + mDx);
final int y = (int) (mSelectedStartY + mDy);
// 判斷拖動距離的閾值是否達到view寬或高的一半
if (Math.abs(y - viewHolder.itemView.getTop()) < viewHolder.itemView.getHeight() * threshold
&& Math.abs(x - viewHolder.itemView.getLeft())
< viewHolder.itemView.getWidth() * threshold) {
return;
}
// 查找所有和選中view有交叉重疊的其他child,並預計算之間的距離
List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
if (swapTargets.size() == 0) {
return;
}
// may swap.
// 找到一個可交換的ViewHolder。(以垂直拖動爲例,若往上拖拽,則比較選中view的上邊界
// 是否小於目標view的上邊界,往下拖拽則以下選中view和目標view的下邊界作爲臨界值。
// 如果有多個view滿足,以差值最大的作爲目標view)
ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y);
if (target == null) {
// 若沒有找到可交換的ViewHolder,則清空集合返回
mSwapTargets.clear();
mDistances.clear();
return;
}
final int toPosition = target.getAdapterPosition();
final int fromPosition = viewHolder.getAdapterPosition();
// 回調Callback.onMove,我們在此方法中進行適配器數據集中的item交換
if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
// 若進行了數據交換,onMove需要返回true。
// onMoved的默認實現中會判斷目標view的邊界是否超出RecyclerView和LayoutManager是否支持對應方向滾動,
// 進而調用RecyclerView.scrollToPosition方法,滾動到指定索引位置。
// keep target visible
mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
target, toPosition, x, y);
}
}
可見moveIfNecessary方法是drag拖動的關鍵,在拖動過程中判斷選中view和其他view的邊界作爲臨界值,作爲觸發onMove的條件。並且在item交換成功後,還會判斷目標view是否超出RecyclerView,繼而觸發滾動。
回到onTouchEvent方法的ACTION_MOVE case中,在執行完moveIfNecessary後,接着執行mScrollRunnable.run(),看看這個方法:
final Runnable mScrollRunnable = new Runnable() {
@Override
public void run() {
// scrollIfNecessary若有滾動則返回true
if (mSelected != null && scrollIfNecessary()) {
if (mSelected != null) { //it might be lost during scrolling
moveIfNecessary(mSelected);
}
mRecyclerView.removeCallbacks(mScrollRunnable);
ViewCompat.postOnAnimation(mRecyclerView, this);
}
}
};
scrollIfNecessary方法中判斷選中view的邊界是否超出RecyclerView和LayoutManager是否支持對應方向滾動,若滿足則計算滾動偏移量,並通過Callback.interpolateOutOfBoundsScroll計算差值偏移,最後調用RecyclerView.scrollBy觸發滾動。
mScrollRunnable和moveIfNecessary中都有可能觸發滾動。區別是mScrollRunnable中是當選中view拖拽超出邊界時,通過RecyclerView.scrollBy方法滾動一定偏移距離。moveIfNecessary中時當交換item後,判斷目標view超出邊界,通過RecyclerView.scrollToPosition方法滾動到目標view指定索引位置。
總結
通過對swipe和drag的過程的源碼分析,將ItemTouchHelper拆解爲初始註冊綁定、事件託管、事件攔截處理、SWIPE和DRAG觸發判定、選中View的拖動和釋放處理、DRAG交換和超出邊界滾動等部分,對ItemTouchHelper的實現機制有了大概的瞭解。