RecyclerView 滑动多选的分析与实现(三)

方案二: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 滚动区

自动滚动实现

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 之类的了,因为这个库能直接复制使用、定制修改,没必要搞复杂。最终,在这里放一个实现后的效果图吧,主要是看看拖动模式与滑动模式。

DragMultiSelectRecyclerView

正如前文所说,网格布局下由于长按拖动之后是要进行上下滚动的,所以在网格布局下就不要开启滑动选择模式了。具体的使用方法请直接查看 Mupceet/DragMultiSelectRecyclerView 吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,812评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,626评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,144评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,052评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,925评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,035评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,461评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,150评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,413评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,501评论 2 307
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,277评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,159评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,528评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,868评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,143评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,407评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,615评论 2 335

推荐阅读更多精彩内容

  • 为什么要做滑动多选? 废话啊,当然是因为 UE 说要做啦! 可以看到众多 ROM 的系统应用都实现了滑动多选的功能...
    Mupceet阅读 2,627评论 1 1
  • 方案三: AndroidDragSelect 前文说到,方案三就是分析了方案一的缺点之后,给出了自己的基于 OnI...
    Mupceet阅读 1,736评论 0 2
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,038评论 25 707
  • 我是一粒寂寞的尘埃 偶然飘进了你的温怀 请不要惊怪 也不要扑拍 或许你根本就不明白 这不是彼此的青睐 也不是我的无...
    半个读书人阅读 393评论 92 55
  • 晚自习结束了,我伸伸腰,望向窗外,夜已经很深了,便加快脚步走出校门。一抬头,便能望见父亲站在黑夜里如同一棵挺拔...
    路承于远阅读 266评论 0 1