為什么要做滑動(dòng)多選?
廢話啊,當(dāng)然是因?yàn)?UE 說要做啦!
可以看到眾多 ROM 的系統(tǒng)應(yīng)用都實(shí)現(xiàn)了滑動(dòng)多選的功能,例如三星的文件管理器,OPPO 的短信等等,不知道來源是不是 Google 相冊(cè)。因?yàn)榻换ド吓c Google 相冊(cè)的策略都是一致的。
Photos 的策略
這里是 Google Photos 的效果:

可以看到策略為:
- 長(zhǎng)按時(shí)選擇該條目,并進(jìn)入多選模式
- 往外滑動(dòng)時(shí)選擇滑動(dòng)過的條目
- 往回滑動(dòng)時(shí)取消選擇條目
- 多選模式單擊時(shí)反選
前三條規(guī)則是不論原先條目的被選擇狀態(tài)是怎樣的。
UE 的策略
而根據(jù) UE 的描述,我需要實(shí)現(xiàn)的多選功能中的多選可以表述為拖動(dòng)多選和滑動(dòng)多選兩種情況,什么意思呢?見下面具體的策略。
- 長(zhǎng)按時(shí)進(jìn)入拖動(dòng)多選模式,即手指不放手可進(jìn)行拖動(dòng)選擇
- 手指抬起后,依然處于多選模式,此時(shí)叫做滑動(dòng)多選,因?yàn)檫@時(shí)在指定的區(qū)域內(nèi)滑動(dòng)可選擇
- 多選模式單擊時(shí)反選
我們的應(yīng)用有線性布局和網(wǎng)格布局的列表,在線性布局中才有兩種選擇模式,在網(wǎng)格布局中沒有滑動(dòng)多選模式。
兩種模式的選擇策略是一樣的:
- 反選手指按下時(shí)的條目(稱為第一條目)
- 往外滑動(dòng)過的條目狀態(tài)與第一條目改變后的狀態(tài)一致
- 往回滑動(dòng)時(shí)條目恢復(fù)原先的狀態(tài)
當(dāng)然,由于要長(zhǎng)按才進(jìn)入多選模式,所以拖動(dòng)選擇實(shí)際上與 Photos 的拖動(dòng)選擇效果是一樣的,只是在滑動(dòng)選擇中與之效果不同。
這里應(yīng)該有一個(gè)圖的,但是等我把代碼擼完再上咯,先上 GitHub 找一下相關(guān)的庫。
首先呢,肯定是考慮基于 RecyclerView 實(shí)現(xiàn)的庫, 因?yàn)樵谖覀兊膸讉€(gè)應(yīng)用中是使用 RecyclerView 實(shí)現(xiàn)了線性布局和網(wǎng)格布局的列表,因此搜索時(shí)找到了以下這幾個(gè)庫:
- afollestad/drag-select-recyclerview:1.1k ★
- MFlisar/DragSelectRecyclerView:267 ★
- weidongjian/AndroidDragSelect-SimulateGooglePhoto:19 ★
這三個(gè)庫之間的關(guān)系是:
- 方案一是鼻祖,而且從 Start 數(shù)量也可以看出來,讓很多人受到啟發(fā),GitHub 上有基于它的自定義 RecyclerView 的想法自定義了 GridView 的實(shí)現(xiàn)。其選擇策略與 Photos 相同,但是這個(gè)庫最大的缺點(diǎn)就是耦合度太高,不適合集成,具體后面分析會(huì)說到;
- 方案三就是分析了方案一的缺點(diǎn)之后,給出了自己的基于
OnItemTouchListener的實(shí)現(xiàn)方案,耦合度低,可以很容易集成進(jìn)現(xiàn)有的項(xiàng)目當(dāng)中。而且增加了動(dòng)畫的設(shè)置,其最終效果:選擇策略與動(dòng)畫效果,與 Photos 幾乎一致。 - 方案二則是在方案三的基礎(chǔ)上進(jìn)行改進(jìn)的,它們使用了相同的自動(dòng)滾動(dòng)的方案,但是選擇策略更多樣,更人性化,并對(duì)超出列表區(qū)域時(shí)是否自動(dòng)滾動(dòng)做了處理。
接下來,我們分別對(duì)這幾個(gè)庫進(jìn)行分析、比較,最后完成符合我們要求的滑動(dòng)多選的庫。
方案一:drag-select-recyclerview
這里主要是看一下其設(shè)計(jì)的思路,所以只分析了自定義 RecyclerView 的部分,對(duì)于自定義 Adapter 的代碼不做分析,只簡(jiǎn)單提一下。以下對(duì)其進(jìn)行分析時(shí)會(huì)對(duì)代碼順序做出一定的調(diào)整以符合分析流程。
public class DragSelectRecyclerView extends RecyclerView {
可以看到,此庫是基于自定義一個(gè) RecyclerView 的想法去實(shí)現(xiàn)的。在此自定義 View 中,設(shè)置滾動(dòng)區(qū);處理觸摸事件使得手指在滾動(dòng)區(qū)時(shí)列表自動(dòng)滾動(dòng);手指滑動(dòng)過程中對(duì)經(jīng)過的范圍進(jìn)行選擇處理。
接下來我們逐步進(jìn)行分析,在最后總結(jié)一下這個(gè)庫的實(shí)現(xiàn)有哪些缺點(diǎn)。
滾動(dòng)區(qū)的定義
先看一下滾動(dòng)區(qū)的定義,自定義 RecyclerView 通過設(shè)置三個(gè)屬性,然后進(jìn)行計(jì)算確認(rèn)滾動(dòng)區(qū)。以下三個(gè)屬性值的禁止?fàn)顟B(tài)用 -1 表示;
private int hotspotHeight; // 滑動(dòng)熱區(qū)的高度,默認(rèn)為 56dp
private int hotspotOffsetTop; // 頂部的滑動(dòng)熱區(qū)距離控件頂部的高度,默認(rèn)為 0
private int hotspotOffsetBottom; // 底部的滑動(dòng)熱區(qū)距離控件底部的高度,默認(rèn)為 0
<resources>
<declare-styleable name="DragSelectRecyclerView">
<!--滾動(dòng)熱區(qū)的高度-->
<attr name="dsrv_autoScrollHotspotHeight" format="dimension"/>
<!--是否禁止?jié)L動(dòng)-->
<attr name="dsrv_autoScrollEnabled" format="boolean"/>
<!--滾動(dòng)熱區(qū)上邊距-->
<attr name="dsrv_autoScrollHotspot_offsetTop" format="dimension"/>
<!--滾動(dòng)熱區(qū)下邊距-->
<attr name="dsrv_autoScrollHotspot_offsetBottom" format="dimension"/>
</declare-styleable>
</resources>
可以看到開放給 xml 設(shè)置的屬性有高度、上邊距、下邊距,還有禁止?jié)L動(dòng)。當(dāng)禁止?jié)L動(dòng)的時(shí)候?qū)⑷齻€(gè)值置為 -1。
通過以上三個(gè)屬性值,在 onMeasure() 中確定滾動(dòng)區(qū)的幾個(gè)有用的坐標(biāo)值:
// 上滑動(dòng)熱區(qū)上邊的上邊距:坐標(biāo) = hotspotOffsetTop
private int hotspotTopBoundStart;
// 上滑動(dòng)熱區(qū)下邊的上邊距:坐標(biāo) = hotspotOffsetTop + hotspotHeight
private int hotspotTopBoundEnd;
// 下滑動(dòng)熱區(qū)上邊的上邊距:坐標(biāo) = (getMeasuredHeight() - hotspotHeight) - hotspotOffsetBottom
private int hotspotBottomBoundStart;
// 下滑動(dòng)熱區(qū)下邊的上邊距:坐標(biāo) = getMeasuredHeight() - hotspotOffsetBottom
private int hotspotBottomBoundEnd;
以下這張圖可以直觀查看這些變量的含義。

如何自動(dòng)滾動(dòng)
先看一下手指滑動(dòng)到滾動(dòng)區(qū)域時(shí),列表自動(dòng)滾動(dòng)是怎樣做到的。
答案就是使用一個(gè) Handler 每 25ms post Runnable 調(diào)用滾動(dòng)的方法并更新滾動(dòng)速度。
通過手指是在上部滾動(dòng)區(qū)還是下部滾動(dòng)區(qū)來決定滾動(dòng)的方向,滾動(dòng)的速度通過 autoScrollVelocity 這個(gè)變量來控制。
private int autoScrollVelocity; // 自動(dòng)滾動(dòng)時(shí)的速度,這個(gè)速度隨著與邊距的距離大小而改變
private Runnable autoScrollRunnable =
new Runnable() {
@Override
public void run() {
if (autoScrollHandler == null) {
return;
}
if (inTopHotspot) {// 上滾動(dòng)區(qū)
scrollBy(0, -autoScrollVelocity);
autoScrollHandler.postDelayed(this, AUTO_SCROLL_DELAY);
} else if (inBottomHotspot) { // 下滾動(dòng)區(qū)
scrollBy(0, autoScrollVelocity);
autoScrollHandler.postDelayed(this, AUTO_SCROLL_DELAY);
}
}
};
如何選擇條目
選擇條目的更新主要就是通過以下 4 個(gè)變量記錄手指的活動(dòng)范圍,在手指活動(dòng)時(shí)記錄、更新變量,然后對(duì)相應(yīng)位置的條目進(jìn)行選擇操作。
private int lastDraggedIndex; // 手指停下來的位置
private int initialSelection; // 手指點(diǎn)擊開始滑動(dòng)的位置
private int minReached; // 手指滑動(dòng)過程中到過的最小下標(biāo)
private int maxReached; // 手指滑動(dòng)過程中到過的最大下標(biāo)
以上的位置值使用 RecyclerView.NO_POSITION 表示初始狀態(tài),后續(xù)在需要的時(shí)候要對(duì)其進(jìn)行重置。
記錄起點(diǎn)
記錄起點(diǎn)是通過調(diào)用激活拖動(dòng)多選的方法 setDragSelectActive(boolean active, int initialSelection) 時(shí)進(jìn)行記錄的,這個(gè)方法供長(zhǎng)按條目 onLongClick() 時(shí)調(diào)用,主要完成的功能:
- 選中長(zhǎng)按的條目
- 記錄此次長(zhǎng)按拖動(dòng)多選的起點(diǎn)
// 使用一個(gè)標(biāo)志位開啟滑動(dòng)多選的功能
private boolean dragSelectActive; // 此值為真時(shí),觸摸事件的分發(fā)時(shí)才會(huì)進(jìn)行處理
// 需要使用自定義 Adapter
private DragSelectRecyclerViewAdapter<?> adapter;
public boolean setDragSelectActive(boolean active, int initialSelection) {
// 已經(jīng)激活了直接返回
if (active && dragSelectActive) {
LOG("Drag selection is already active.");
return false;
}
lastDraggedIndex = -1;
minReached = -1;
maxReached = -1;
// 判斷點(diǎn)擊的位置是不是可選擇的(Adapter)
if (!adapter.isIndexSelectable(initialSelection)) {
dragSelectActive = false;
this.initialSelection = -1;
lastDraggedIndex = -1;
LOG("Index %d is not selectable.", initialSelection);
return false;
}
// 選中長(zhǎng)按的條目(Adapter)
adapter.setSelected(initialSelection, true);
dragSelectActive = active;
// 記錄此次拖動(dòng)選擇的起點(diǎn)
this.initialSelection = initialSelection;
lastDraggedIndex = initialSelection;
if (fingerListener != null) {
fingerListener.onDragSelectFingerAction(true);
}
LOG("Drag selection initialized, starting at index %d.", initialSelection);
return true;
}
處理觸摸事件
接下來看看如何處理具體的觸摸事件,可以看到自定義的 RecyclerView 是在觸摸事件的分發(fā) dispatchTouchEvent() 中對(duì)手指活動(dòng)事件進(jìn)行處理的。手指是否進(jìn)入滾動(dòng)區(qū)的判斷、滾動(dòng)速度的設(shè)定、以及經(jīng)過了哪些條目的信息的更新都在這里進(jìn)行處理。
主要流程為:
- 只在拖動(dòng)多選被激活時(shí)才進(jìn)行處理
- 處理抬起手指
ACTION_UP與手指滑動(dòng)ACTION_MOVE兩個(gè)事件- 抬起手指,重置狀態(tài),移除滾動(dòng)的 Callback
- 手指滑動(dòng)時(shí)判斷是在哪個(gè)區(qū)域,進(jìn)行相應(yīng)的處理
- 滾動(dòng)時(shí)在觸摸到的條目發(fā)生變化時(shí)會(huì)更新那 4 個(gè)位置信息,從而在 Adapter 中選中 initial 到 last 之間的條目,清除 min 到 max 之間除了 initial 到 last 條目之外的條目
先看一下位于哪個(gè)區(qū)域的判斷與處理部分:
@Override
public boolean dispatchTouchEvent(MotionEvent e) {
if (adapter.getItemCount() == 0) return super.dispatchTouchEvent(e);
// 只在拖動(dòng)多選被激活時(shí)才進(jìn)行處理
if (dragSelectActive) {
// 獲取觸摸時(shí)對(duì)應(yīng)的條目位置下標(biāo)
final int itemPosition = getItemPosition(e);
// 抬起手指,重置狀態(tài),移除滾動(dòng)的 Callback
if (e.getAction() == MotionEvent.ACTION_UP) {
dragSelectActive = false;
inTopHotspot = false;
inBottomHotspot = false;
autoScrollHandler.removeCallbacks(autoScrollRunnable);
if (fingerListener != null) {
fingerListener.onDragSelectFingerAction(false);
}
return true;
} else if (e.getAction() == MotionEvent.ACTION_MOVE) {
// Check for auto-scroll hotspot
if (hotspotHeight > -1) {
// 滑動(dòng)時(shí)判斷是在哪個(gè)區(qū)域:分為三種,上部、下部、非滾動(dòng)區(qū)
// 以在上部為例
if (e.getY() >= hotspotTopBoundStart && e.getY() <= hotspotTopBoundEnd) {
inBottomHotspot = false;
if (!inTopHotspot) {
// 進(jìn)入上部滾動(dòng)區(qū)時(shí),移除原先的Runnable,重新Post
// 原因是滾動(dòng)的觸發(fā)需要延遲25ms
inTopHotspot = true;
LOG("Now in TOP hotspot");
autoScrollHandler.removeCallbacks(autoScrollRunnable);
autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_DELAY);
}
// 根據(jù)手指與滾動(dòng)區(qū)的邊距設(shè)置滾動(dòng)速度
final float simulatedFactor = hotspotTopBoundEnd - hotspotTopBoundStart;
final float simulatedY = e.getY() - hotspotTopBoundStart;
autoScrollVelocity = (int) (simulatedFactor - simulatedY) / 2;
LOG("Auto scroll velocity = %d", autoScrollVelocity);
} else if (e.getY() >= hotspotBottomBoundStart
&& e.getY() <= hotspotBottomBoundEnd) {
inTopHotspot = false;
if (!inBottomHotspot) {
inBottomHotspot = true;
LOG("Now in BOTTOM hotspot");
autoScrollHandler.removeCallbacks(autoScrollRunnable);
autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_DELAY);
}
final float simulatedY = e.getY() + hotspotBottomBoundEnd;
final float simulatedFactor = hotspotBottomBoundStart + hotspotBottomBoundEnd;
autoScrollVelocity = (int) (simulatedY - simulatedFactor) / 2;
LOG("Auto scroll velocity = %d", autoScrollVelocity);
} else if (inTopHotspot || inBottomHotspot) {
LOG("Left the hotspot");
autoScrollHandler.removeCallbacks(autoScrollRunnable);
inTopHotspot = false;
inBottomHotspot = false;
}
}
// ...
// 省略更新手指范圍的代碼,放到后文
return true;
}
}
return super.dispatchTouchEvent(e);
}
其中 getItemPosition() 是獲取觸摸時(shí)對(duì)應(yīng)的條目位置的方法,這個(gè)方法主要兩個(gè)功能:
- 判斷一下此 RecyclerView 使用的 Adapter 是不是正確繼承了自定義的 Adapter
- 前一個(gè)條件成立時(shí),返回此時(shí)觸摸事件對(duì)應(yīng)的條目位置
為什么要判斷是否正確繼承自定義 Adapte 呢?這是因?yàn)榉桨敢坏膶懛ㄐ枰玫?ViewHolder,從而才得到得位置信息。
private int getItemPosition(MotionEvent e) {
final View v = findChildViewUnder(e.getX(), e.getY());
if (v == null) return NO_POSITION;
if (v.getTag() == null || !(v.getTag() instanceof ViewHolder)) {
throw new IllegalStateException(
"Make sure your adapter makes a call to super.onBindViewHolder(), "
+ "and doesn't override itemView tags.");
}
final ViewHolder holder = (ViewHolder) v.getTag();
return holder.getAdapterPosition();
}
實(shí)際上,這里可能更多的是因?yàn)榇俗远x View 調(diào)用了相應(yīng)的自定義 Adapter 中的方法,所以在這里對(duì)是否使用了相應(yīng)的 Adapter 進(jìn)行檢查,否則是沒有必要的,寫成如下形式即可:
private int getItemPosition(MotionEvent e) {
final View v = findChildViewUnder(e.getX(), e.getY());
if (v == null) return NO_POSITION;
return getChildAdapterPosition(v);
}
選中滑過的條目
以下就是上面省略的更新手指選擇范圍的代碼。在手指滑動(dòng)到新的條目時(shí)進(jìn)行變量的更新,在更新選擇范圍之后會(huì)通過 Adapter 對(duì)條目進(jìn)行選擇操作。
// 自動(dòng)滾動(dòng)時(shí)在條目發(fā)生變化時(shí)會(huì)更新那4個(gè)條目位置信息
// Drag selection logic
if (itemPosition != NO_POSITION
&& lastDraggedIndex != itemPosition) {
lastDraggedIndex = itemPosition;
if (minReached == -1) {
minReached = lastDraggedIndex;
}
if (maxReached == -1) {
maxReached = lastDraggedIndex;
}
if (lastDraggedIndex > maxReached) {
maxReached = lastDraggedIndex;
}
if (lastDraggedIndex < minReached) {
minReached = lastDraggedIndex;
}
if (adapter != null) {
// 在adapter中選中 initial 到 last 的條目,清除選中 min 到 max 除了前面要選中條目之外的條目
adapter.selectRange(initialSelection, lastDraggedIndex, minReached, maxReached);
}
if (initialSelection == lastDraggedIndex) {
minReached = lastDraggedIndex;
maxReached = lastDraggedIndex;
}
}
DragSelectRecyclerViewAdapter
可以看到在 DragSelectRecyclerView 需要與 DragSelectRecyclerViewAdapter 搭配使用。因?yàn)樾枰{(diào)用 Adapter 進(jìn)行選擇處理。當(dāng)然了,這個(gè)可以通過回調(diào)抽出來。
Adapter 中需要實(shí)現(xiàn)、處理的是:
onBindViewHolder
將 VH 通過 Tag 設(shè)置到本身的 View 上。這樣在 RecyclerView 中就可以通過 VH 獲得在 Adapter 中的 position。
注:如上面所說,這一步不是必要的。只是通過這樣可以檢查是否使用了自定義的 Adapter。
@CallSuper
@Override
public void onBindViewHolder(VH holder, int position) {
holder.itemView.setTag(holder);
}
選擇的方法
主要有:
-
setSelected(int index, boolean selected):設(shè)置對(duì)應(yīng)條目的選擇狀態(tài) -
toggleSelected(int index):反選對(duì)應(yīng)條目,并返回新狀態(tài) -
selectRange(int from, int to, int min, int max):將 from 到 to 的位置的狀態(tài)保持一致,反選另外的。 -
selectAll()clearSelected():全選、取消選中條目
缺點(diǎn)
- 這種方法使用了自定義 RecyclerView 與 Adapter 并相互之間發(fā)生了耦合,使用時(shí)就需要更改原來的 RecyclerView 和 Adapter 的繼承與代碼,不優(yōu)雅。
- 可以看到選擇范圍的更新是在手指滑動(dòng)時(shí)進(jìn)行的,所以手指在滾動(dòng)區(qū)按住不動(dòng)時(shí)列表發(fā)生滾動(dòng)但沒有選擇上,而在手指動(dòng)了之后才會(huì)正確選中。
- 無選擇動(dòng)畫效果
后幾點(diǎn)都可以修復(fù),但其相互耦合的方式是導(dǎo)致它無法被采用的根本原因,必須考慮其他的實(shí)現(xiàn)方式。