1. 程式人生 > >RecyclerView 拖動/滑動多選的實現(2)

RecyclerView 拖動/滑動多選的實現(2)

方案三: AndroidDragSelect

前文說到,方案三就是分析了方案一的缺點之後,給出了自己的基於 OnItemTouchListener 的實現方案,耦合度低,可以很容易整合進現有的專案當中。

從自定義 RecyclerView 的方案中可以看到,它是在事件分發的時候進行處理。事實上,在這個方法裡做計算感覺上就有點不對,從原始碼來看,RecyclerView 本身是沒有重寫 dispatchTouchEvent() 方法的,而方案一通過重寫此方法並在這裡完成自動滾動的計算處理,顯得有些重。

回顧一下事件分發機制,其中 dispatchTouchEvent() 用來進行事件的分發,onInterceptTouchEvent()

被前一個方法呼叫,用來進行判斷是否進行攔截,真正地處理點選事件則是在 onTouchEvent() 當中。所以方案三就是利用了 RecyclerView 的 OnItemTouchListener 來對觸控事件進行攔截處理。

在檢視方案三的原始碼之前,我們先來看一下 RecyclerView 中的這個 OnItemTouchListener 介面:

OnItemTouchListener

從原始碼註釋可以看出,三個方法是在與 RecyclerView 同一檢視層級上對事件進行處理的,也就是在分發給子 View 之前。

public static interface OnItemTouchListener
{
// public boolean onInterceptTouchEvent(MotionEvent e) public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e); // public boolean onTouchEvent(MotionEvent e) public void onTouchEvent(RecyclerView rv, MotionEvent e); // public void requestDisallowInterceptTouchEvent(boolean disallowIntercept)
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept); }

其中註釋是 ViewGroup 中這三個方法的定義,可以看到除了 onRequestDisallowInterceptTouchEvent() 方法之外,其他兩個都有一點小差別。

onInterceptTouchEvent() 這個方法引數不一樣,onTouchEvent() 除了引數不一樣,返回值也變了,變成了無返回值。那麼也就可以猜測,如果 OnItemTouchListener 處理了點選事件,就不會再交由父 View 再進行處理了。到底是不是這樣子呢,我們通過 RecyclerView 的原始碼檢視一下。

@Override
public boolean onInterceptTouchEvent(MotionEvent e) {
    // 省略程式碼……
    if (dispatchOnItemTouchIntercept(e)) {
        cancelTouch();
        return true;
    }
    // 省略程式碼……
}

再進一步檢視 dispatchOnItemTouchIntercept() 可以看到,如果新增的 OnItemTouchListener 它攔截了 MotionEvent 事件,那麼就返回 true,此時 RecyclerView 也返回 true 表明攔截了此次事件不再由子 View 進行處理。

再去看看 RecyclerView 的 onTouchEvent() 方法,看是不是同樣地把這個事件交由 OnItemTouchListener 來處理。

@Override
public boolean onTouchEvent(MotionEvent e) {
    // 省略程式碼……
    if (dispatchOnItemTouch(e)) {
        cancelTouch();
        return true;
    }
    // 省略程式碼……
}

dispatchOnItemTouchIntercept() 類似的,如果新增的 OnItemTouchListener 它攔截了 MotionEvent 事件,那麼就由它在 onTouchEvent() 中進行處理。這裡再稍微看一下 dispatchOnItemTouch() 來解決一個實踐中的小困惑:OnItemTouchListener 裡在 onInterceptTouchEvent() 中對於 MotionEvent.ACTION_DOWN 無論是否返回 true,都不會在 onTouchEvent 裡收到此 MotionEvent。

private boolean dispatchOnItemTouch(MotionEvent e) {
    if (mActiveOnItemTouchListener != null) {
        if (action == MotionEvent.ACTION_DOWN) {
            // Stale state from a previous gesture, we're starting a new one. Clear it.
            mActiveOnItemTouchListener = null;
        } else {
            mActiveOnItemTouchListener.onTouchEvent(this, e);
            if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
                // Clean up for the next gesture.
                mActiveOnItemTouchListener = null;
            }
            return true;
        }
    }
    // 省略程式碼……
}

可以看到 OnItemTouchListener 中的 onInterceptTouchEvent() 是無法接收到 MotionEvent.ACTION_DOWN 的。

接下來對方案三進行分析,在正式分析之前,先吐槽一句,大家還是比較喜歡 Start 可以直接拿來用的庫,因為這個方案三 weidongjian/AndroidDragSelect-SimulateGooglePhoto:19 ★ 是個 Demo,引數、設定比較粗糙,導致了星星好少,但其設計思路是很好的。而方案二 MFlisar/DragSelectRecyclerView:267 ★ 就是在其基礎上進行的改進,兩者的共同點就是 OnItemTouchListener,它們幾乎是一樣的。而方案三的滑動多選也就只是通過這一個類來實現的,所以下文以方案二程式碼來具體分析,它的程式碼更規範一點,但是方案二程式碼裡面大括號是放在行首以及 if 程式碼塊沒有大括號讓我很難受……

滾動區的定義

方案三的滾動區設定比較簡單,我就直接上圖了,其實這個圖也不對,可能原作者是這樣子想的,但是原始碼裡的那個 mTopBound 設定得不對。

方案三滾動區

方案二與方案一的滾動區設定一模一樣,只是名稱改了一下。

方案二滾動區

自動滾動實現

方案一使用的是一種通過 Handler 的 postDelayed 方法的延時策略,可以在大約每 25ms 時滾動一下,這裡使用大約就是因為 Handler 的排程也是需要時間的。在本方案中,使用 Scroller 來實現流暢地滾動,Scroller 的使用、講解可以看《Android 開發藝術探索》及網上資料來學習。具體就見下面的程式碼:

public void startAutoScroll() {
    if (recyclerView == null) {
        return;
    }
    // 建立 Scroller
    if (scroller == null) {
        scroller = ScrollerCompat.create(recyclerView.getContext(),
                new LinearInterpolator());
    }
    if (scroller.isFinished()) {
        recyclerView.removeCallbacks(scrollRun);
        // 設定引數,這裡只有100000是有意義的,它代表
        // 手指在滾動區完全靜止不動時最多可持續滾動100s
        scroller.startScroll(0, scroller.getCurrY(), 0, 5000, 100000);
        ViewCompat.postOnAnimation(recyclerView, scrollRun);
    }
}

public void stopAutoScroll() {
    if (scroller != null && !scroller.isFinished()) {
        recyclerView.removeCallbacks(scrollRun);
        scroller.abortAnimation();
    }
}

private Runnable scrollRun = new Runnable() {
    @Override
    public void run() {
        if (scroller != null && scroller.computeScrollOffset()) {
            scrollBy(scrollDistance);
            ViewCompat.postOnAnimation(recyclerView, scrollRun);
        }
    }
};

private void scrollBy(int distance) {
    int scrollDistance;
    // 限制滾動速度
    if (distance > 0) {
        scrollDistance = Math.min(distance, MAX_SCROLL_DISTANCE);
    } else {
        scrollDistance = Math.max(distance, -MAX_SCROLL_DISTANCE);
    }
    recyclerView.scrollBy(0, scrollDistance);
    // 自動滾動時的選擇範圍的更新在這裡,因為只在自動滾動時這兩個才有合法值
    if (lastX != Float.MIN_VALUE && lastY != Float.MIN_VALUE) {
        updateSelectedRange(recyclerView, lastX, lastY);
    }
}

觸控事件的處理

onInterceptTouchEvent

首先是 onInterceptTouchEvent() 方法,簡單來說,在這裡判斷一下滑動選擇功能是否啟用,只在啟用時候才攔截觸控事件;事實上,由於長按才 active,所以攔截不到 MotionEvent.ACTION_DOWN 事件,而它將在長按之後處理接收到的第一個 MotionEvent.ACTION_MOVE 事件,在這裡進行引數的初始化。後續再接收到的 MotionEvent 就全部都由 onTouchEvent() 方法來處理了。

@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
    if (!mIsActive || rv.getAdapter().getItemCount() == 0)
        return false;

    int action = MotionEventCompat.getActionMasked(e);
    switch (action) {
        // 事實上,由於長按才active,所以以下兩個case是不會收到的
        case MotionEvent.ACTION_POINTER_DOWN:
        case MotionEvent.ACTION_DOWN:
            reset();
            break;
    }
    // 引數設定
    mRecyclerView = rv;
    int height = rv.getHeight();
    mTopBoundFrom = mTouchRegionTopOffset;
    mTopBoundTo = mTouchRegionTopOffset + mAutoScrollDistance;
    mBottomBoundFrom = height - mTouchRegionBottomOffset - mAutoScrollDistance;
    mBottomBoundTo = height - mTouchRegionBottomOffset;
    return true;
}

onTouchEvent

這裡就是對 Move 事件進行自動滾動、更新選擇範圍的處理。

@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
    if (!mIsActive) {
        return;
    }

    int action = MotionEventCompat.getActionMasked(e);
    switch (action) {
        case MotionEvent.ACTION_MOVE:
            // 將此方法提前,因為檢視方法可以知道它只處理滾動區內的事件,
            // 包括自動滾動、更新選擇範圍
            processAutoScroll(e);
            if (!mInTopSpot && !mInBottomSpot) {
                // 不在滾動區內的只要更新選擇範圍
                updateSelectedRange(rv, e);
            }
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_POINTER_UP:
            // 退出時重置狀態
            reset();
            break;
    }
}

自動滾動的實現方式上面已經提過了,這裡的的自動滾動處理主要是解決三個問題:

  • 記錄手指最後位置,以便在手指不動時還可以更新選擇範圍
  • 手指是否在滾動區的判斷,以及是否允許滾動區之上的滾動
  • 根據手指在滾動區的位置更新“速度”值
private void processAutoScroll(MotionEvent event) {
    int y = (int) event.getY();
    if (y >= mTopBoundFrom && y <= mTopBoundTo) {
        // 嚴格位於上滾動區內
        mLastX = event.getX();
        mLastY = event.getY();
        // 計算速度 = maxSpeed * (手指離上滾動區下邊界的距離 / 上滾動區的高度)
        // 往上滾速度為負數
        mScrollSpeedFactor = (mTopBoundTo - y) / (float)mAutoScrollDistance;
        mScrollDistance = (int) (mMaxScrollDistance * mScrollSpeedFactor * -1f);
        if (!mInTopSpot) {
            mInTopSpot = true;
            startAutoScroll();
        }
    } else if (mScrollAboveTopRegion && y < mTopBoundFrom) {
        // 允許在上滾動區之上進行自動滾動
        mLastX = event.getX();
        mLastY = event.getY();
        mScrollDistance = mMaxScrollDistance * -1;
        if (!mInTopSpot) {
            mInTopSpot = true;
            startAutoScroll();
        }
    } else if (y >= mBottomBoundFrom && y <= mBottomBoundTo) {
        mLastX = event.getX();
        mLastY = event.getY();
        mScrollSpeedFactor = ((y - mBottomBoundFrom)) / (float)mAutoScrollDistance;
        mScrollDistance = (int) ((float) mMaxScrollDistance * mScrollSpeedFactor);
        if (!mInBottomSpot) {
            mInBottomSpot = true;
            startAutoScroll();
        }
    } else if (mScrollBelowTopRegion && y > mBottomBoundTo) {
        mLastX = event.getX();
        mLastY = event.getY();
        mScrollDistance = mMaxScrollDistance;
        if (!mInBottomSpot) {
            mInBottomSpot = true;
            startAutoScroll();
        }
    } else {
        // 不在滾動區內重置資料
        mInBottomSpot = false;
        mInTopSpot = false;
        mLastX = Float.MIN_VALUE;
        mLastY = Float.MIN_VALUE;
        stopAutoScroll();
    }
}

選擇範圍的更新與回撥

上面看到,在自動滾動時進行選擇範圍的更新。先來簡單看一下更新範圍的更新方法:

private void updateSelectedRange(RecyclerView rv, float x, float y) {
    View child = rv.findChildViewUnder(x, y);
    if (child != null) {
        int position = rv.getChildAdapterPosition(child);
        if (position != RecyclerView.NO_POSITION && mEnd != position) {
            mEnd = position;
            // 在手指到達新的條目時再通知更新
            notifySelectRangeChange();
        }
    }
}

可見重點在於 notifySelectRangeChange() 方法。這段程式碼可以結合圖來理解。

座標圖.png

首先明確一些條件:

  • 手指按下的地方為 start,手指當前的地方為 end。但它們的大小關係不定。
  • start 與 end 之間的條目一定是被選中的。
  • newStart 代表現在 start 與 end 兩者中較小者,newEnd 代表較大者
  • lastStart 和 lastEnd 與 newStart 和 newEnd 含義相同,但指的是未更新前的位置

事實上,如果是列表型,那麼因為這個範圍不會跳變,所以 lastStart 和 lastEnd 與 newStart 和 newEnd 只會相差 1。但如果是網格型列表,可以上下行滑動時範圍就會跳變。

private void notifySelectRangeChange() {
    if (mSelectListener == null)
        return;
    if (mStart == RecyclerView.NO_POSITION || mEnd == RecyclerView.NO_POSITION)
        return;

    int newStart, newEnd;
    newStart = Math.min(mStart, mEnd);
    newEnd = Math.max(mStart, mEnd);
    if (mLastStart == RecyclerView.NO_POSITION || mLastEnd == RecyclerView.NO_POSITION) {
        if (newEnd - newStart == 1)
            mSelectListener.onSelectChange(newStart, newStart, true);
        else
            mSelectListener.onSelectChange(newStart, newEnd, true);
    } else {
        // 重點看這四句,對照著座標圖可以看懂的
        if (newStart > mLastStart)
            mSelectListener.onSelectChange(mLastStart, newStart - 1, false);
        else if (newStart < mLastStart)
            // 此條件下如圖,應該把它們之間的選中。而lastStart之前已經選中了。
            mSelectListener.onSelectChange(newStart, mLastStart - 1, true);
        if (newEnd > mLastEnd)
            mSelectListener.onSelectChange(mLastEnd + 1, newEnd, true);
        else if (newEnd < mLastEnd)
            // 此條件下如圖,應該把它們之間的取消選中。而lastEnd之前已經選中了也要取消。
            mSelectListener.onSelectChange(newEnd + 1, mLastEnd, false);
    }

    mLastStart = newStart;
    mLastEnd = newEnd;
}

那麼這個範圍就是通過回撥來通知監聽者的。

public interface OnDragSelectListener {
    /**
     * @param start      the newly (un)selected range start
     * @param end        the newly (un)selected range end
     * @param isSelected true, it range got selected, false if not
     */
    void onSelectChange(int start, int end, boolean isSelected);
}

在我的理解中 start 與 end 之間的條目的選中狀態是指一種狀態,它可以代表是選擇條目的狀態,也可以是不選擇條目的狀態,具體來說就是選中與未選中是兩種狀態,我們指定 true 代表某一種狀態,從而使用 false 代表另一種狀態,因此方法 void onSelectChange(int start, int end, boolean isSelected) 的引數 3 確切地說應該命名為 state。這樣子再重新理解一下上面 notifySelectRangeChange 中的那重要的四句話就會明白它指的是:

  • 指定 start 與 end 之間的條目的狀態為 A
  • 根據座標圖,將 newStart 和 newEnd 之間的狀態也置為 A,另外的則更新狀態為非 A

以上說有這個狀態相關內容,如果不是太理解,可以看看我的實現方案,它是在對方案二進行再次修訂而成的,對於此內容會有更好的理解。

方案一回調為 selectRange(initialSelection, lastDraggedIndex, minReached, maxReached) 有 4 個引數,相當於把方案二的 lastStart、lastEnd、newStart 和 newEnd 全部傳回來。但實際上,傳回之後也是採用同樣的處理方式,因此將選擇與反選的操作放到 OnItemTouchListener 裡會更方便。

方案三的使用與效果

到目前為止,基於這一個單純的回撥,就可以完成 Google 的選擇策略了。實現也非常地簡單:

touchListener.setSelectListener(new DragSelectTouchListener.onSelectListener() {
    @Override
    public void onSelectChange(int start, int end, boolean isSelected) {
        //選擇的範圍回撥
        adapter.selectRangeChange(start, end, isSelected);
        actionBar.setTitle(String.valueOf(adapter.getSelectedSize()) + " selected");
    }
});

是不是特別地簡潔?但是這裡有兩點要注意,

  • 一個是由於回撥 onSelectChange() 非常頻繁,所以在 Adapter 裡的相應的選擇的方法 selectRangeChange 一定要注意判斷一下條目的原先的狀態,也就是說如果狀態沒有改變,那麼就什麼都不做,如果狀態更改了,才去更新狀態:notifyItemChanged()
  • 另一個是,由於 Item Change 時預設帶著動畫,所以在滾動時如果速度比較快、條目比較寬,就會看到明顯的殘影。如果沒有自定義的動畫可以採用以下方法去除預設的 Change 動畫即可:
    Java
    ((SimpleItemAnimator)recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
    // 或者
    recyclerView.getItemAnimator().setChangeDuration(0);

    如果有動畫的話可能不行,動畫的內容我後續會進行實踐,並且還會看看使用 OnItemTouchListener 實現的 Click 事件回撥方案與此滑動方案的相容性。大家可以自行測試、處理。