RecyclerView 滑動多選的分析與實現(三)

方案二:DragSelectRecyclerView

擴展的選擇策略

之前提到,方案二是基于方案三進行擴展的,可以看到,在 OnItemTouchListener 這一塊,兩者其實幾乎是一模一樣的。而方案二一個很好的地方,就是在幾乎不修改 DragSelectTouchListener 的前提下,對其選擇功能進行了強大方便的擴展。下面我將從設計的思路出發,理一理是怎樣完成的。

首先要清楚方案二擴展了哪些選擇策略,總共有 4 種模式:

  • Simple: 滑過時選中,往回滑時取消選中
  • ToggleAndUndo: 滑過時反選,往回滑時恢復原狀態
  • FirstItemDependent: 反選按下的第一條目,滑過時與第一條目狀態一致,往回滑時與第一條目狀態相反
  • FirstItemDependentToggleAndUndo: 反選按下的第一條目,滑過時與第一條目狀態一致,往回滑時恢復原狀態

關于這 4 種模式的效果請看 GIF 圖:

Simple ToggleAndUndo
FirstItemDependent FirstItemDependentToggleAndUndo

第 1 種模式其實就是 Google Photos 的策略,而第 4 種策略與我需要實現的基本相同(感動地哭出聲……)。看了效果之后,我們再想想基于方案三的一個回調 onSelectChange(int start, int end, boolean isSelected) 能完成嗎?

首先可以知道 Simple 模式是可以做到的,因為這個模式下除了位置信息之外無需另外的信息。而另外三種都無法做到,因為它們都需要按下時列表目前的狀態信息:

  • ToggleAndUndo 需要知道按下時,哪些條目已經被選擇了,這樣子才能恢復原狀態;
  • FirstItemDependent 需要知道按下時的條目的原狀態,才能反選第一條目;
  • FirstItemDependentToggleAndUndo 需要知道的就包括前兩者的信息:哪些條目被選擇了、第一條目的原狀態(事實上,這一信息包含在前一個信息里)。

那么,很自然地,在按下時也需要一個回調,以此為入口獲取所需要的信息了。因此方案二先擴展了 DragSelectTouchListener.OnDragSelectListener 的接口,首先看一下對 OnDragSelectListener 這個接口的擴展:

public interface OnAdvancedDragSelectListener extends OnDragSelectListener{
    void onSelectionStarted(int start);
    void onSelectionFinished(int end);
}

// 增加了新接口后在原代碼邏輯中增加調用邏輯
public void startDragSelection(int position){
    // 省略代碼...
    if (mSelectListener != null && mSelectListener instanceof OnAdvancedDragSelectListener) {
        ((OnAdvancedDragSelectListener)mSelectListener).onSelectionStarted(position);
    }        
}

private void reset() {
    // 省略代碼...
    if (mSelectListener != null && mSelectListener instanceof OnAdvancedDragSelectListener)
        ((OnAdvancedDragSelectListener) mSelectListener).onSelectionFinished(mEnd);
}

可以看到,只是繼承增加了兩個接口,分別在點擊開始、結束時被調用。實現該接口獲取點擊時列表的狀態信息了,也就可以通過這些信息實現擴展的選擇策略。

DragSelectionProcessor

方案二在擴展了 DragSelectTouchListener 后,將其實現封裝了一層,把這 4 種模式放到一個控制器里:

public class DragSelectionProcessor implements DragSelectTouchListener.OnAdvancedDragSelectListener {}

看看它是怎么在按下時的回調 onSelectionStarted() 中獲得信息的呢?

@Override
public void onSelectionStarted(int start) {
    mOriginalSelection = new HashSet<>();
    Set<Integer> selected = mSelectionHandler.getSelection();
    if (selected != null)
        mOriginalSelection.addAll(selected);
    mFirstWasSelected = mOriginalSelection.contains(start);
    // 省略代碼...
}

從上面代碼中可以看到,正是在開始選擇的回調中獲取了列表中已選擇項的信息,而且這也是使用了一個接口 ISelectionHandler 來獲取信息的:

public interface ISelectionHandler {
    Set<Integer> getSelection();
    void updateSelection(int start, int end, boolean isSelected, boolean calledFromOnStart);
    boolean isSelected(int index);
}

可以看到該接口還有兩個回調函數,那另外兩個方法是做什么的呢?看一下上面 onSelectionStarted() 省略的代碼:

@Override
public void onSelectionStarted(int start) {
    // 省略代碼...
    switch (mMode) {
        case Simple: {
            mSelectionHandler.updateSelection(start, start, true, true);
            break;
        }
        case ToggleAndUndo: {
            mSelectionHandler.updateSelection(start, start, !mOriginalSelection.contains(start), true);
            break;
        }
        case FirstItemDependent: {
            mSelectionHandler.updateSelection(start, start, !mFirstWasSelected, true);
            break;
        }
        case FirstItemDependentToggleAndUndo: {
            mSelectionHandler.updateSelection(start, start, !mFirstWasSelected, true);
            break;
        }
    }
}

也就是對于不同模式下,得到了第一個條目的信息,要更新第一條目的狀態,比如說 FirstItemDependent 就是要反選第一條目,所以 updateSelection() 這個方法就是調用具體設置狀態的方法的。而在 onSelectChange() 回調中,我們看到更新狀態變成了另一個方法 checkedUpdateSelection()

@Override
public void onSelectChange(int start, int end, boolean isSelected) {
    switch (mMode) {
        case Simple: {
            if (mCheckSelectionState)
                checkedUpdateSelection(start, end, isSelected);
            else
                mSelectionHandler.updateSelection(start, end, isSelected, false);
            break;
        }
        case ToggleAndUndo: {
            for (int i = start; i <= end; i++)
                checkedUpdateSelection(i, i, isSelected ? !mOriginalSelection.contains(i) : mOriginalSelection.contains(i));
            break;
        }
        case FirstItemDependent: {
            checkedUpdateSelection(start, end, isSelected ? !mFirstWasSelected : mFirstWasSelected);
            break;
        }
        case FirstItemDependentToggleAndUndo: {
            for (int i = start; i <= end; i++)
                checkedUpdateSelection(i, i, isSelected ? !mFirstWasSelected : mOriginalSelection.contains(i));
            break;
        }
    }
}
private void checkedUpdateSelection(int start, int end, boolean newSelectionState) {
    if (mCheckSelectionState) {
        for (int i = start; i <= end; i++) {
            if (mSelectionHandler.isSelected(i) != newSelectionState)
                mSelectionHandler.updateSelection(i, i, newSelectionState, false);
        }
    } else
        mSelectionHandler.updateSelection(start, end, newSelectionState, false);
}

一下子就明白了,isSelected() 是獲取某一條目的選擇狀態的,可以用來檢測原來列表的狀態的選項。也就是說,如果原列表的某條目的狀態 mSelectionHandler.isSelected(i) 如果與新狀態不同的話,才需要更新該條目的狀態。這個的原因其實之前也說過了,對于相同的狀態就不要調用 Adapter 的方法去重新設置了,這是一種浪費。

拿 FirstItemDependentToggleAndUndo 模式下的選擇:checkedUpdateSelection(i, i, isSelected ? !mFirstWasSelected : mOriginalSelection.contains(i)) 來理解一下。

onSelectChange 這一回調中,第三個參數 isSelected 意義為 true 時想要將 i 條目狀態設置為選中,false 時想要將 i 條目狀態設置為取消選中。對于 FirstItemDependentToggleAndUndo 模式來說,true 代表 i 條目要與 start 條目的現狀態相同,所以是 !mFirstWasSelected,false 不代表與 start 條目相反,而是代表 i 條目要恢復到原來的狀態,所以變成了 mOriginalSelection.contains(i)。這種設計真是妙極。

當然了,前面也說到,這個 DragSelectionProcessor 就是對 DragSelectTouchListener.OnAdvancedDragSelectListener 的擴展的一個封裝,而 DragSelectTouchListener.OnAdvancedDragSelectListener 是對 DragSelectTouchListener.OnDragSelectListener 的擴展。因此,如果你只需要實現 Simple 模式也就是 Google Photos 的選擇模式的話,直接實現 DragSelectTouchListener.OnDragSelectListener 就可以了。

onDragSelectionListener = new DragSelectTouchListener.OnDragSelectListener() {
    @Override
    public void onSelectChange(int start, int end, boolean isSelected) {
     // update your selection
     // range is inclusive start/end positions
    }
}

如果需要在點擊開始與結束時做一些操作,只需要實現 DragSelectTouchListener.OnAdvancedDragSelectListener

onDragSelectionListener = new DragSelectTouchListener.OnAdvancedDragSelectListener() {
   @Override
   public void onSelectChange(int start, int end, boolean isSelected) {
     // update your selection
     // range is inclusive start/end positions
   }

   @Override
   public void onSelectionStarted(int start) {
     // drag selection was started at index start
   }

   @Override
   public void onSelectionFinished(int end) {
     // drag selection was finished at index start
   }
};

而如果想要使用擴展出來的 3 種模式,可以基于 OnAdvancedDragSelectListener 自己進行實現,也可以直接使用封裝好的 DragSelectionProcessor。

onDragSelectionListener = new DragSelectionProcessor(new DragSelectionProcessor.ISelectionHandler() {
    @Override
    public Set<Integer> getSelection() {
        // return a set of all currently selected indizes
        return selection;
    }

    @Override
    public boolean isSelected(int index) {
        // return the current selection state of the index
        return selected;
    }

    @Override
    public void updateSelection(int start, int end, boolean isSelected, boolean calledFromOnStart) {
        // update your selection
        // range is inclusive start/end positions
        // and the processor has already converted all events according to it'smode
    }
})
    // pass in one of the 4 modes, simple mode is selected by default otherwise
    .withMode(DragSelectionProcessor.Mode.FirstItemDependentToggleAndUndo);

mDragSelectTouchListener = new DragSelectTouchListener()
    // check region OnDragSelectListener for more infos
    .withSelectListener(onDragSelectionListener);

具體的代碼以及使用示例請直接查看 MFlisar/DragSelectRecyclerView 的 README 文檔。至此,GitHub 上的三個庫都分析完畢了,DragSelectRecyclerView 是完整度最好的,接下來是時候來擼一個自已的支持網格列表及常規列表的拖動、滑動多選的庫了。

DragMultiSelectRecyclerView

滾動區的定義

DragMultiSelectRecyclerView 的滾動區的定義與方案二一致:

DragMultiSelectRecyclerView 滾動區

自動滾動實現

DragMultiSelectRecyclerView 的自動滾動的實現與方案二、三是完全一致的,這里就不贅述。

觸摸事件的處理

增加了滑動多選模式,具體的改動見代碼中的注釋。

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

    int action = e.getAction();
    int actionMask = action & MotionEvent.ACTION_MASK;

    switch (actionMask) {
        case MotionEvent.ACTION_DOWN:
            // 記錄按下時的坐標,避免在滾動區觸發反向滾動
            mActionDownY = e.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            // 此標志位在長按時激活拖動多選時設置
            if (mIsDragActive) {
                return true;
            }
            // 開戶滑動多選模式時,會在拖動結束時進入滑動多選
            // 并且在定義的滑動區域內的事件才會攔截處理
            if (mIsSlideActive && isInSlideArea(e)) {
                activeSlideSelect(getItemPosition(rv, e));
                return true;
            }
            break;
    }

    // 其他情況則不攔截
    return false;
}

其中 init() 方法為初始化具體參數:

private void init(RecyclerView rv) {
    if (mHasInit) {// 只初始化一次
        return;
    }
    if (mRecyclerView == null) {
        mRecyclerView = rv;
    }
    int rvHeight = rv.getHeight();
    if (mHotspotHeight == -1f) { // 未設置滾動區的高度,采用(RV高度×比例)
        mHotspotHeight = rvHeight * mHotspotHeightRatio;
    } else { // 表明設置了滾動區的大小
        if (mHotspotHeight >= rvHeight / 2) {
            mHotspotHeight = rvHeight / 2;
        }
    }
    mTopRegionFrom = mHotspotOffset;
    mTopRegionTo = mTopRegionFrom + mHotspotHeight;
    mBottomRegionTo = rvHeight - mHotspotOffset;
    mBottomRegionFrom = mBottomRegionTo - mHotspotHeight;

    mHasInit = true;
}

onInterceptTouchEvent() 中對是否攔截進行處理后,具體的事件處理如下:

@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
    if (!isActive()) {
        return;
    }
    int action = e.getAction();
    int actionMask = action & MotionEvent.ACTION_MASK;

    switch (actionMask) {
        case MotionEvent.ACTION_MOVE:
            processAutoScroll(e);
            if (!mIsInTopHotspot && !mIsInBottomHotspot) {
                updateSelectedRange(rv, e);
            }
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            // 抬起手指時分成兩種情況
            if (!mIsDisableSlide) {// 允許滑動模式
                mIsDragActive = false;
                mIsSlideActive = true;// 轉換為滑動模式
                selectFinished();
            } else {// 退出多選模式
                mIsDragActive = false;
                mIsSlideActive = false;
                selectFinished();
            }
            break;
    }
}

選擇范圍的更新與回調

DragMultiSelectRecyclerView 按下時激活多選模式,記錄此時的位置,與方案二相比,這里增加了按下時對該位置進行一次選擇的調用:

public void activeDragSelect(int position) {
    mStart = position;
    mEnd = position;
    mLastStart = position;
    mLastEnd = position;
    if (!mIsDragActive && !mIsSlideActive) {
        mIsDragActive = true;
    }
    if (mSelectListener != null) {
        if (mSelectListener instanceof OnAdvancedDragSelectListener) {
            ((OnAdvancedDragSelectListener) mSelectListener).onSelectionStarted(position);
        }
        // 增加一次主動調用,方案二中 onSelectionStarted 要對第一條目進行處理,
        // 實際上第一條目的處理與其他條目處理是一致的
        mSelectListener.onSelectChange(position, true);
    }
}

自動滾動的處理同樣是在 processAutoScroll() 中,但是增加了一個判斷,以避免在滾動區中激活選擇模式時觸發了反向滾動,以在上滾動區為例:

// y < mActionDownY 增加此判斷,在上滾動區向下滑動時不會觸發上滾
if (y > mTopRegionFrom && y < mTopRegionTo && y < mActionDownY) {
    mLastX = e.getX();
    mLastY = e.getY();
    float scrollDistanceFactor = (y - mTopRegionTo) / mHotspotHeight;
    mScrollDistance = (int)(mMaxScrollDistance * scrollDistanceFactor);
    if (!mIsInTopHotspot) {
        mIsInTopHotspot = true;
        startAutoScroll();
        // 但如果在上滾動區向上滑動時要正常觸發,此時將此值更新
        // 可以正常觸發滾動與速度的更新
        mActionDownY = mTopRegionTo;
    }
}

選擇范圍的更新同樣是在 notifySelectRangeChange() 中,其中具體的更新在原來方案二的實現中為:

private void notifySelectRangeChange() {
    // 省略代碼……
        // 重點看這四句,對照著坐標圖可以看懂的
        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;
}

而在接口的實現中,同樣要調用 Adapter 的 selectRange 方法。前文說過,這里的參數實際上就是指一個狀態,而且對于選擇范圍里的每一條目都要回調一次,那么就沒有必要將這個 start 與 end 傳遞出去。故在這里我把 mSelectListener.onSelectChange(newEnd + 1, mLastEnd, false) 改成 mSelectListener.onSelectChange(i, newState),而這只是進行了一下轉換:

private void selectChange(int start, int end, boolean newState) {
    for (int i = start; i <= end; i++) {
        mSelectListener.onSelectChange(i, newState);
    }
}

這樣子實際上最終 OnItemTouchListener 要實現的接口為:

public interface OnDragSelectListener {
    /**
     * @param position 此條目狀態將轉變為 new state
     * @param newState 新的狀態
     */
    void onSelectChange(int position, boolean newState);
}

public interface OnAdvancedDragSelectListener extends OnDragSelectListener {
    /**
     * @param start 選擇開始于此
     */
    void onSelectionStarted(int start);

    /**
     * @param end 選擇結束于此
     */
    void onSelectionFinished(int end);
}

DragSelectionProcessor

在 OnItemTouchListener 中進行修改之后,實際上 DragSelectionProcessor 的實現也顯得更簡潔一些:

@Override
public void onSelectionStarted(int start) {
    mOriginalSelection = new HashSet<>();
    Set<Integer> selected = mSelectionHandler.getSelection();
    if (selected != null) {
        mOriginalSelection.addAll(selected);
    }
    mFirstWasSelected = mOriginalSelection.contains(start);

    if (mStartFinishedListener != null) {
        mStartFinishedListener.onSelectionStarted(start, mFirstWasSelected);
    }
    // 選擇開始時的回調只專心于獲取當前的狀態即可,對 start 條目無須進行處理
}

@Override
public void onSelectionFinished(int end) {
    mOriginalSelection = null;

    if (mStartFinishedListener != null) {
        mStartFinishedListener.onSelectionFinished(end);
    }
}

@Override
public void onSelectChange(int position, boolean newState) {
    // 省略代碼……
    // 此處的實現與方案二幾乎一致,具體可以查看代碼
}

private void checkedUpdateSelection(int position, boolean newState) {
    if (mCheckSelectionState) {
        if (mSelectionHandler.isSelected(position) != newState ) {
            mSelectionHandler.updateSelection(position, newState);
        }
    } else {
        // 同樣地將 updateSelection(int start, int end, boolean isSelected, boolean calledFromOnStart) 換成 updateSelection(position, newState) 更加地直觀
        mSelectionHandler.updateSelection(position, newState);
    }
}

也就是說對應的 ISelectionHandler 只修改了一下 updateSelection() 方法的參數。

public interface ISelectionHandler {
    Set<Integer> getSelection();
    void updateSelection(int start, int end, boolean isSelected, boolean calledFromOnStart);
    boolean isSelected(int index);
}

到此為此,一個使用 OnItemTouchListener 實現 RecyclerView 拖動/滑動多選的功能的庫就完成了。使用時按需要直接復制一個或兩個類就好了,我就不搞什么 compile 之類的了,因為這個庫能直接復制使用、定制修改,沒必要搞復雜。最終,在這里放一個實現后的效果圖吧,主要是看看拖動模式與滑動模式。

DragMultiSelectRecyclerView

正如前文所說,網格布局下由于長按拖動之后是要進行上下滾動的,所以在網格布局下就不要開啟滑動選擇模式了。具體的使用方法請直接查看 Mupceet/DragMultiSelectRecyclerView 吧。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 為什么要做滑動多選? 廢話啊,當然是因為 UE 說要做啦! 可以看到眾多 ROM 的系統應用都實現了滑動多選的功能...
    Mupceet閱讀 2,806評論 1 1
  • 方案三: AndroidDragSelect 前文說到,方案三就是分析了方案一的缺點之后,給出了自己的基于 OnI...
    Mupceet閱讀 1,814評論 0 2
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,596評論 25 708
  • 我是一粒寂寞的塵埃 偶然飄進了你的溫懷 請不要驚怪 也不要撲拍 或許你根本就不明白 這不是彼此的青睞 也不是我的無...
    半個讀書人閱讀 418評論 92 55
  • 晚自習結束了,我伸伸腰,望向窗外,夜已經很深了,便加快腳步走出校門。一抬頭,便能望見父親站在黑夜里如同一棵挺拔...
    路承于遠閱讀 285評論 0 1