為什么要做滑動多選笛坦?
廢話啊区转,當(dāng)然是因為 UE 說要做啦!
可以看到眾多 ROM 的系統(tǒng)應(yīng)用都實現(xiàn)了滑動多選的功能版扩,例如三星的文件管理器废离,OPPO 的短信等等,不知道來源是不是 Google 相冊礁芦。因為交互上與 Google 相冊的策略都是一致的蜻韭。
Photos 的策略
這里是 Google Photos 的效果:
可以看到策略為:
- 長按時選擇該條目,并進入多選模式
- 往外滑動時選擇滑動過的條目
- 往回滑動時取消選擇條目
- 多選模式單擊時反選
前三條規(guī)則是不論原先條目的被選擇狀態(tài)是怎樣的柿扣。
UE 的策略
而根據(jù) UE 的描述肖方,我需要實現(xiàn)的多選功能中的多選可以表述為拖動多選和滑動多選兩種情況,什么意思呢未状?見下面具體的策略俯画。
- 長按時進入拖動多選模式,即手指不放手可進行拖動選擇
- 手指抬起后娩践,依然處于多選模式活翩,此時叫做滑動多選烹骨,因為這時在指定的區(qū)域內(nèi)滑動可選擇
- 多選模式單擊時反選
我們的應(yīng)用有線性布局和網(wǎng)格布局的列表,在線性布局中才有兩種選擇模式材泄,在網(wǎng)格布局中沒有滑動多選模式沮焕。
兩種模式的選擇策略是一樣的:
- 反選手指按下時的條目(稱為第一條目)
- 往外滑動過的條目狀態(tài)與第一條目改變后的狀態(tài)一致
- 往回滑動時條目恢復(fù)原先的狀態(tài)
當(dāng)然,由于要長按才進入多選模式拉宗,所以拖動選擇實際上與 Photos 的拖動選擇效果是一樣的峦树,只是在滑動選擇中與之效果不同。
這里應(yīng)該有一個圖的旦事,但是等我把代碼擼完再上咯魁巩,先上 GitHub 找一下相關(guān)的庫。
首先呢姐浮,肯定是考慮基于 RecyclerView 實現(xiàn)的庫谷遂, 因為在我們的幾個應(yīng)用中是使用 RecyclerView 實現(xiàn)了線性布局和網(wǎng)格布局的列表,因此搜索時找到了以下這幾個庫:
- afollestad/drag-select-recyclerview:1.1k ★
- MFlisar/DragSelectRecyclerView:267 ★
- weidongjian/AndroidDragSelect-SimulateGooglePhoto:19 ★
這三個庫之間的關(guān)系是:
- 方案一是鼻祖卖鲤,而且從 Start 數(shù)量也可以看出來肾扰,讓很多人受到啟發(fā),GitHub 上有基于它的自定義 RecyclerView 的想法自定義了 GridView 的實現(xiàn)蛋逾。其選擇策略與 Photos 相同集晚,但是這個庫最大的缺點就是耦合度太高,不適合集成区匣,具體后面分析會說到偷拔;
- 方案三就是分析了方案一的缺點之后,給出了自己的基于
OnItemTouchListener
的實現(xiàn)方案亏钩,耦合度低莲绰,可以很容易集成進現(xiàn)有的項目當(dāng)中。而且增加了動畫的設(shè)置铸屉,其最終效果:選擇策略與動畫效果钉蒲,與 Photos 幾乎一致。 - 方案二則是在方案三的基礎(chǔ)上進行改進的彻坛,它們使用了相同的自動滾動的方案顷啼,但是選擇策略更多樣,更人性化昌屉,并對超出列表區(qū)域時是否自動滾動做了處理钙蒙。
接下來,我們分別對這幾個庫進行分析间驮、比較躬厌,最后完成符合我們要求的滑動多選的庫。
方案一:drag-select-recyclerview
這里主要是看一下其設(shè)計的思路,所以只分析了自定義 RecyclerView 的部分扛施,對于自定義 Adapter 的代碼不做分析鸿捧,只簡單提一下。以下對其進行分析時會對代碼順序做出一定的調(diào)整以符合分析流程疙渣。
public class DragSelectRecyclerView extends RecyclerView {
可以看到匙奴,此庫是基于自定義一個 RecyclerView 的想法去實現(xiàn)的。在此自定義 View 中妄荔,設(shè)置滾動區(qū)泼菌;處理觸摸事件使得手指在滾動區(qū)時列表自動滾動;手指滑動過程中對經(jīng)過的范圍進行選擇處理啦租。
接下來我們逐步進行分析哗伯,在最后總結(jié)一下這個庫的實現(xiàn)有哪些缺點。
滾動區(qū)的定義
先看一下滾動區(qū)的定義篷角,自定義 RecyclerView 通過設(shè)置三個屬性焊刹,然后進行計算確認滾動區(qū)。以下三個屬性值的禁止?fàn)顟B(tài)用 -1 表示内地;
private int hotspotHeight; // 滑動熱區(qū)的高度伴澄,默認為 56dp
private int hotspotOffsetTop; // 頂部的滑動熱區(qū)距離控件頂部的高度,默認為 0
private int hotspotOffsetBottom; // 底部的滑動熱區(qū)距離控件底部的高度阱缓,默認為 0
<resources>
<declare-styleable name="DragSelectRecyclerView">
<!--滾動熱區(qū)的高度-->
<attr name="dsrv_autoScrollHotspotHeight" format="dimension"/>
<!--是否禁止?jié)L動-->
<attr name="dsrv_autoScrollEnabled" format="boolean"/>
<!--滾動熱區(qū)上邊距-->
<attr name="dsrv_autoScrollHotspot_offsetTop" format="dimension"/>
<!--滾動熱區(qū)下邊距-->
<attr name="dsrv_autoScrollHotspot_offsetBottom" format="dimension"/>
</declare-styleable>
</resources>
可以看到開放給 xml 設(shè)置的屬性有高度、上邊距举农、下邊距荆针,還有禁止?jié)L動。當(dāng)禁止?jié)L動的時候?qū)⑷齻€值置為 -1颁糟。
通過以上三個屬性值航背,在 onMeasure()
中確定滾動區(qū)的幾個有用的坐標值:
// 上滑動熱區(qū)上邊的上邊距:坐標 = hotspotOffsetTop
private int hotspotTopBoundStart;
// 上滑動熱區(qū)下邊的上邊距:坐標 = hotspotOffsetTop + hotspotHeight
private int hotspotTopBoundEnd;
// 下滑動熱區(qū)上邊的上邊距:坐標 = (getMeasuredHeight() - hotspotHeight) - hotspotOffsetBottom
private int hotspotBottomBoundStart;
// 下滑動熱區(qū)下邊的上邊距:坐標 = getMeasuredHeight() - hotspotOffsetBottom
private int hotspotBottomBoundEnd;
以下這張圖可以直觀查看這些變量的含義。
如何自動滾動
先看一下手指滑動到滾動區(qū)域時棱貌,列表自動滾動是怎樣做到的玖媚。
答案就是使用一個 Handler 每 25ms post Runnable 調(diào)用滾動的方法并更新滾動速度。
通過手指是在上部滾動區(qū)還是下部滾動區(qū)來決定滾動的方向婚脱,滾動的速度通過 autoScrollVelocity
這個變量來控制今魔。
private int autoScrollVelocity; // 自動滾動時的速度,這個速度隨著與邊距的距離大小而改變
private Runnable autoScrollRunnable =
new Runnable() {
@Override
public void run() {
if (autoScrollHandler == null) {
return;
}
if (inTopHotspot) {// 上滾動區(qū)
scrollBy(0, -autoScrollVelocity);
autoScrollHandler.postDelayed(this, AUTO_SCROLL_DELAY);
} else if (inBottomHotspot) { // 下滾動區(qū)
scrollBy(0, autoScrollVelocity);
autoScrollHandler.postDelayed(this, AUTO_SCROLL_DELAY);
}
}
};
如何選擇條目
選擇條目的更新主要就是通過以下 4 個變量記錄手指的活動范圍障贸,在手指活動時記錄错森、更新變量,然后對相應(yīng)位置的條目進行選擇操作篮洁。
private int lastDraggedIndex; // 手指停下來的位置
private int initialSelection; // 手指點擊開始滑動的位置
private int minReached; // 手指滑動過程中到過的最小下標
private int maxReached; // 手指滑動過程中到過的最大下標
以上的位置值使用 RecyclerView.NO_POSITION
表示初始狀態(tài)涩维,后續(xù)在需要的時候要對其進行重置。
記錄起點
記錄起點是通過調(diào)用激活拖動多選的方法 setDragSelectActive(boolean active, int initialSelection)
時進行記錄的袁波,這個方法供長按條目 onLongClick()
時調(diào)用瓦阐,主要完成的功能:
- 選中長按的條目
- 記錄此次長按拖動多選的起點
// 使用一個標志位開啟滑動多選的功能
private boolean dragSelectActive; // 此值為真時蜗侈,觸摸事件的分發(fā)時才會進行處理
// 需要使用自定義 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;
// 判斷點擊的位置是不是可選擇的(Adapter)
if (!adapter.isIndexSelectable(initialSelection)) {
dragSelectActive = false;
this.initialSelection = -1;
lastDraggedIndex = -1;
LOG("Index %d is not selectable.", initialSelection);
return false;
}
// 選中長按的條目(Adapter)
adapter.setSelected(initialSelection, true);
dragSelectActive = active;
// 記錄此次拖動選擇的起點
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()
中對手指活動事件進行處理的睡蟋。手指是否進入滾動區(qū)的判斷宛篇、滾動速度的設(shè)定、以及經(jīng)過了哪些條目的信息的更新都在這里進行處理薄湿。
主要流程為:
- 只在拖動多選被激活時才進行處理
- 處理抬起手指
ACTION_UP
與手指滑動ACTION_MOVE
兩個事件- 抬起手指叫倍,重置狀態(tài),移除滾動的 Callback
- 手指滑動時判斷是在哪個區(qū)域豺瘤,進行相應(yīng)的處理
- 滾動時在觸摸到的條目發(fā)生變化時會更新那 4 個位置信息吆倦,從而在 Adapter 中選中 initial 到 last 之間的條目,清除 min 到 max 之間除了 initial 到 last 條目之外的條目
先看一下位于哪個區(qū)域的判斷與處理部分:
@Override
public boolean dispatchTouchEvent(MotionEvent e) {
if (adapter.getItemCount() == 0) return super.dispatchTouchEvent(e);
// 只在拖動多選被激活時才進行處理
if (dragSelectActive) {
// 獲取觸摸時對應(yīng)的條目位置下標
final int itemPosition = getItemPosition(e);
// 抬起手指坐求,重置狀態(tài)蚕泽,移除滾動的 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) {
// 滑動時判斷是在哪個區(qū)域:分為三種,上部桥嗤、下部须妻、非滾動區(qū)
// 以在上部為例
if (e.getY() >= hotspotTopBoundStart && e.getY() <= hotspotTopBoundEnd) {
inBottomHotspot = false;
if (!inTopHotspot) {
// 進入上部滾動區(qū)時,移除原先的Runnable泛领,重新Post
// 原因是滾動的觸發(fā)需要延遲25ms
inTopHotspot = true;
LOG("Now in TOP hotspot");
autoScrollHandler.removeCallbacks(autoScrollRunnable);
autoScrollHandler.postDelayed(autoScrollRunnable, AUTO_SCROLL_DELAY);
}
// 根據(jù)手指與滾動區(qū)的邊距設(shè)置滾動速度
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()
是獲取觸摸時對應(yīng)的條目位置的方法,這個方法主要兩個功能:
- 判斷一下此 RecyclerView 使用的 Adapter 是不是正確繼承了自定義的 Adapter
- 前一個條件成立時渊鞋,返回此時觸摸事件對應(yīng)的條目位置
為什么要判斷是否正確繼承自定義 Adapte 呢绰更?這是因為方案一的寫法需要拿到 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();
}
實際上儡湾,這里可能更多的是因為此自定義 View 調(diào)用了相應(yīng)的自定義 Adapter 中的方法,所以在這里對是否使用了相應(yīng)的 Adapter 進行檢查执俩,否則是沒有必要的徐钠,寫成如下形式即可:
private int getItemPosition(MotionEvent e) {
final View v = findChildViewUnder(e.getX(), e.getY());
if (v == null) return NO_POSITION;
return getChildAdapterPosition(v);
}
選中滑過的條目
以下就是上面省略的更新手指選擇范圍的代碼。在手指滑動到新的條目時進行變量的更新役首,在更新選擇范圍之后會通過 Adapter 對條目進行選擇操作尝丐。
// 自動滾動時在條目發(fā)生變化時會更新那4個條目位置信息
// 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 搭配使用宋税。因為需要調(diào)用 Adapter 進行選擇處理摊崭。當(dāng)然了,這個可以通過回調(diào)抽出來杰赛。
Adapter 中需要實現(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è)置對應(yīng)條目的選擇狀態(tài) -
toggleSelected(int index)
:反選對應(yīng)條目蛤迎,并返回新狀態(tài) -
selectRange(int from, int to, int min, int max)
:將 from 到 to 的位置的狀態(tài)保持一致确虱,反選另外的。 -
selectAll()
clearSelected()
:全選替裆、取消選中條目
缺點
- 這種方法使用了自定義 RecyclerView 與 Adapter 并相互之間發(fā)生了耦合校辩,使用時就需要更改原來的 RecyclerView 和 Adapter 的繼承與代碼,不優(yōu)雅辆童。
- 可以看到選擇范圍的更新是在手指滑動時進行的宜咒,所以手指在滾動區(qū)按住不動時列表發(fā)生滾動但沒有選擇上,而在手指動了之后才會正確選中把鉴。
- 無選擇動畫效果
后幾點都可以修復(fù)故黑,但其相互耦合的方式是導(dǎo)致它無法被采用的根本原因,必須考慮其他的實現(xiàn)方式庭砍。