方案二: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 的自動滾動的實現與方案二、三是完全一致的,這里就不贅述。
觸摸事件的處理
增加了滑動多選模式,具體的改動見代碼中的注釋。
@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 之類的了,因為這個庫能直接復制使用、定制修改,沒必要搞復雜。最終,在這里放一個實現后的效果圖吧,主要是看看拖動模式與滑動模式。
正如前文所說,網格布局下由于長按拖動之后是要進行上下滾動的,所以在網格布局下就不要開啟滑動選擇模式了。具體的使用方法請直接查看 Mupceet/DragMultiSelectRecyclerView 吧。