RecyclerView 滑動多選的分析與實現(xiàn)(一)

為什么要做滑動多選笛坦?

廢話啊区转,當(dāng)然是因為 UE 說要做啦!

可以看到眾多 ROM 的系統(tǒng)應(yīng)用都實現(xiàn)了滑動多選的功能版扩,例如三星的文件管理器废离,OPPO 的短信等等,不知道來源是不是 Google 相冊礁芦。因為交互上與 Google 相冊的策略都是一致的蜻韭。

Photos 的策略

這里是 Google Photos 的效果:

Photos.gif

可以看到策略為:

  • 長按時選擇該條目,并進入多選模式
  • 往外滑動時選擇滑動過的條目
  • 往回滑動時取消選擇條目
  • 多選模式單擊時反選

前三條規(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)格布局的列表,因此搜索時找到了以下這幾個庫:

  1. afollestad/drag-select-recyclerview:1.1k ★
  2. MFlisar/DragSelectRecyclerView:267 ★
  3. 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ū).png

如何自動滾動

先看一下手指滑動到滾動區(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)過了哪些條目的信息的更新都在這里進行處理薄湿。

主要流程為:

  1. 只在拖動多選被激活時才進行處理
  2. 處理抬起手指 ACTION_UP 與手指滑動 ACTION_MOVE 兩個事件
    • 抬起手指叫倍,重置狀態(tài),移除滾動的 Callback
    • 手指滑動時判斷是在哪個區(qū)域豺瘤,進行相應(yīng)的處理
  3. 滾動時在觸摸到的條目發(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)方式庭砍。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末场晶,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子怠缸,更是在濱河造成了極大的恐慌诗轻,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,692評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件凯旭,死亡現(xiàn)場離奇詭異概耻,居然都是意外死亡,警方通過查閱死者的電腦和手機罐呼,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,482評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來侦高,“玉大人嫉柴,你說我怎么就攤上這事》钋海” “怎么了计螺?”我有些...
    開封第一講書人閱讀 162,995評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長瞧壮。 經(jīng)常有香客問我登馒,道長,這世上最難降的妖魔是什么咆槽? 我笑而不...
    開封第一講書人閱讀 58,223評論 1 292
  • 正文 為了忘掉前任陈轿,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘麦射。我一直安慰自己蛾娶,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,245評論 6 388
  • 文/花漫 我一把揭開白布潜秋。 她就那樣靜靜地躺著蛔琅,像睡著了一般。 火紅的嫁衣襯著肌膚如雪峻呛。 梳的紋絲不亂的頭發(fā)上罗售,一...
    開封第一講書人閱讀 51,208評論 1 299
  • 那天,我揣著相機與錄音钩述,去河邊找鬼寨躁。 笑死,一個胖子當(dāng)著我的面吹牛切距,可吹牛的內(nèi)容都是我干的朽缎。 我是一名探鬼主播,決...
    沈念sama閱讀 40,091評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼谜悟,長吁一口氣:“原來是場噩夢啊……” “哼话肖!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起葡幸,我...
    開封第一講書人閱讀 38,929評論 0 274
  • 序言:老撾萬榮一對情侶失蹤最筒,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后蔚叨,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體床蜘,經(jīng)...
    沈念sama閱讀 45,346評論 1 311
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,570評論 2 333
  • 正文 我和宋清朗相戀三年蔑水,在試婚紗的時候發(fā)現(xiàn)自己被綠了邢锯。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,739評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡搀别,死狀恐怖丹擎,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情歇父,我是刑警寧澤蒂培,帶...
    沈念sama閱讀 35,437評論 5 344
  • 正文 年R本政府宣布,位于F島的核電站榜苫,受9級特大地震影響护戳,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜垂睬,卻給世界環(huán)境...
    茶點故事閱讀 41,037評論 3 326
  • 文/蒙蒙 一媳荒、第九天 我趴在偏房一處隱蔽的房頂上張望抗悍。 院中可真熱鬧,春花似錦肺樟、人聲如沸檐春。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,677評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽疟暖。三九已至,卻和暖如春田柔,著一層夾襖步出監(jiān)牢的瞬間俐巴,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,833評論 1 269
  • 我被黑心中介騙來泰國打工硬爆, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留欣舵,地道東北人。 一個月前我還...
    沈念sama閱讀 47,760評論 2 369
  • 正文 我出身青樓缀磕,卻偏偏與公主長得像缘圈,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子袜蚕,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,647評論 2 354

推薦閱讀更多精彩內(nèi)容