RecyclerView 滑動(dòng)多選的分析與實(shí)現(xiàn)(一)

為什么要做滑動(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 的效果:

Photos.gif

可以看到策略為:

  • 長(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è)庫:

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

如何自動(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)行處理。

主要流程為:

  1. 只在拖動(dòng)多選被激活時(shí)才進(jìn)行處理
  2. 處理抬起手指 ACTION_UP 與手指滑動(dòng) ACTION_MOVE 兩個(gè)事件
    • 抬起手指,重置狀態(tài),移除滾動(dòng)的 Callback
    • 手指滑動(dòng)時(shí)判斷是在哪個(gè)區(qū)域,進(jìn)行相應(yīng)的處理
  3. 滾動(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)方式。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容