AndroidX RecyclerView總結-ItemTouchHelper

概述

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,效果如圖:

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的實現機制有了大概的瞭解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章