RecyclerView 低耦合单选、多选模块实现

318 篇文章 19 订阅
46 篇文章 2 订阅

作者:丨小夕

前言

需求很简单也很常见,比如有一个数据列表RecyclerView,需要用户去点击选择一个或多个数据。

实现单选的时候往往简单下标记录了事,实现多选的时候就稍微复杂去处理集合和选中。随着项目选中需求增多,不同的地方有了不同的实现,难以维护。

因此本文设计和实现了简单的选择模块去解决此类需求。

本文实现的选择模块主要有以下特点:

  • 不需要改动Adapter,ViewHolder,Item,低耦合
  • 单选,可监听选择变化,手动设置选择位置,支持配置再次点击取消选择
  • 多选,支持全选,反选等
  • 支持数据变化后记录原选择

效果

import me.lwb.adapter.select.isItemSelected

class XxxActivity {
    private val dataAdapter =
        BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
            itemBinding.tips.text = item
            itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
        }

    fun onCreate() {
        val selectModule = dataAdapter.setupSingleSelectModule()//单选
        val selectModule = dataAdapter.setupMultiSelectModule()//多选

        selectModule.doOnSelectChange {

        }
        //...全选,反选等
    }
}

原理

单选

单选的特点:

  1. 用户点击可以选中列表的一个元素 。
  2. 当选择另1个数据会自动取消当前已经选中的,也就是最多选中1个。
  3. 再次点击已经选中的元素取消选中(可配置)。

根据记录选中数据的不同,可以分为下标模式和标识模式,他们各有优缺点。

下标模式

通常情况我们都会这样实现。使用一个记录选中下标的变量selectIndex去标识当前选择,selectIndex=-1表示没有选中任何元素。

原理虽然简单,那么问题来了,变量selectIndex应该放在哪里呢? Adapter?Fragment?Activity?

往往许多人都会选择放在Adapter,觉得数据选中和数据放一起嘛。

实现是实现了,但是往往有更多问题:

  1. 给一个列表增加数据选择功能,需要改造Adapter,侵入性强。
  2. 我要给另外一个列表增加数据选择功能,需要再实现一遍,难复用。
  3. 去除数据选择功能,又需要再改动Adapter,耦合重。

总结起来其实这样实现是不符合单一职责的原则,selectIndex是数据选择功能的数据,Adapter是绑定UI数据的。放在一起改动一方就得牵扯到另外一方。

解决办法就是,单独抽离出选择模块,依赖于Adapter的接口而不是放在Adapter中实现。

得益于BindingAdapter提供的接口,我们首先通过doBeforeBindViewHolder 在绑定时添加Item点击事件的监听,然后切换selectIndex

我们将需要保存的选择数据和行为,单独放在一个模块:

class SingleSelectModule {
    var selectIndex: Int
    var enableUnselect: Boolean

    init {
        adapter.doBeforeBindViewHolder { holder, position ->
            holder.itemView.setOnClickListener {
                toogleSelect(position)
            }
        }
    }

    fun toggleSelect(selectedKey: Int) {
        selectIndex = if (enableUnselect) {
            if (isSelected(selectedKey)) {
                INDEX_UNSELECTED //取消选择
            } else {
                selectedKey //切换选择
            }
        } else {
            selectedKey //切换选择
        }
    }
    //...
}

往往我们需要在onBindViewHolder时判断当前Item是否选中,从而对选中和未选中的Item显示不同的样式。

简单的实现的话可以保存SingleSelectModule引用,然后再onBindViewHolder中获取。

class XxActivity {
    var selectModule: SingleSelectModule
    val adapter =
        BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
            val isItemSelected = selectModule.isSelected(pos)
            itemBinding.tips.text = item
            itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
        }
}

但缺点就是,它又和SingleSelectModule产生了耦合,实际上我们只需要关心当前Item 是否选中即可,要是能给Item加个isItemSelected 属性就好了。

许多的选择方案确实是这么实现的,给Item 添加属性,或者使用Pair<Boolean,Item>去包装,这些方案又造成了一定的侵入性。 我们从另外一个角度,不从Item入手,而是从ViewHolder中去改造,比如这样:

class BindingViewHolder {
    var isItemSelected: Boolean
}

ViewHolder加属性比Item更加通用,起码不用每个需要支持选择的列表都去改造Item

但是逻辑上需要注意:真正选中的是Item,而不是ViewHolder,因为ViewHolder 可能会在不同的时机绑定到不同的Item

所以实际上BindingViewHolder.isItemSelected起到一个桥接作用, 原本的onBindViewHolder内容,是通过val isItemSelected = selectModule.isSelected(pos)获取当前Item是否选中,然后再去使用isItemSelected

现在我们将变量加到ViewHolder后,就不用每次去定义变量了。

    val adapter =
        BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->
            this.isItemSelected = selectModule.isSelected(pos)
            itemBinding.tips.text = item
            itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
        }

同时再把赋值isItemSelected = selectModule.isSelected(pos) 也放入到选择模块中

class SingleSelectModule {

    init {
        adapter.doBeforeBindViewHolder { holder, position ->
            holder.isItemSelected = this.isSelected(pos)
            holder.itemView.setOnClickListener {
                toogleSelect(position)
            }
        }
    }
}

doBeforeBindViewHolder 可以在监听Adapter的onBindViewHolder,并在其前面执行

最后这里就剩下一个问题了,给BindingViewHolder增加isItemSelected 不是又得改ViewHolder吗。还是造成了侵入性, 后续我们还得增加其他模块,总不能每增加一个模块就改一次ViewHolder吧。

那么如何动态的增加属性?

这里我们直接就想到了通过view.setTag/view.getTag(本质上是SparseArray)不就能实现动态添加属性吗, 同时利用上Kotlin的拓展属性,那么它就成了真的"拓展属性"了:

var BindingViewHolder<*>.isItemSelected: Boolean
    set(value) {
        itemView.setTag(R.id.binding_adapter_view_holder_tag_selected, value)
    }
    get() = itemView.getTag(R.id.binding_adapter_view_holder_tag_selected) == true

然后通过引入这个拓展属性import me.lwb.adapter.select.isItemSelected 就能直接在Adapter中访问了, 同理你可以添加任意个拓展属性,并通过doBeforeBindViewHolder来在它们被使用前赋值,这些都不需要改动Adapter或者ViewHolder

import me.lwb.adapter.select.isItemSelected
import me.lwb.adapter.select.isItemSelected2
import me.lwb.adapter.select.isItemSelected3

class XxActivity {
    private val dataAdapter =
        BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->
            //使用isItemSelected isItemSelected2 isItemSelected3
            
            itemBinding.tips.text = item++
            itemBinding.tips.setTextColor(if (isItemSelected) Color.BLUE else Color.BLACK)
        }

}

下标模式十分易用,只需一行代码即可setupSingleSelectModule,但是也有一定局限性,就是用户选中的数据是使用下标来记录的,

如果数据下标对应的数据是变化了,就往往不是我们预期的效果,比如[A,B,C,D],用户选择B,此时selectIndex=1,用户刷新数据变成了[D,C,B,A],这时由于selectIndex=1,虽然选择的都是第2个,但是数据变化了,就变成了选择了C

往往那么经常就只能清空选择了。

标识模式

下标模式适用于数据不变,或者变化后清空选中的情况。

标识模式就是记录数据的唯一标识,可以在数据变化后仍然选中对应的数据,一般Item都会有一个唯一Id可以用作标识。

实现和下标模式接近,但是需要实现获取标识的方法,并且判断选中是根据标识是否相同。

class SingleSelectModuleByKey<I : Any> internal constructor(
    val adapter: MultiTypeBindingAdapter<I, *>,
    val selector: I.() -> Any,
){

    fun isSelected(selectedKey: I?): Boolean {
        val select = selectedItem
        return selectedKey != ITEM_UNSELECTED && select != ITEM_UNSELECTED && selectedKey.selector() == select.selector()
    }
}

使用时指定Item的标识:

adapter.setupSingleSelectModuleByKey { it.id }

多选

多选也分为下标模式和标识模式,原理和单选类似

下标模式

存储选中状态从下标变成了下标集合

class MultiSelectModule<I : Any> internal constructor(
    val adapter: MultiTypeBindingAdapter<I, *>,
) {
    private val mutableSelectedIndexes: MutableSet<Int> = HashSet();
    override fun isSelected(selectKey: Int): Boolean {
        return selectedIndexes.contains(selectKey)
    }
    override fun selectItem(selectKey: Int, choose: Boolean) {
        if (choose) {
            mutableSelectedIndexes.add(selectKey)
        } else {
            mutableSelectedIndexes.remove(selectKey)
        }
        notifyItemsChanged()
    }
    //全选
    override fun selectAll() {
        mutableSelectedIndexes.clear()
        //添加所有索引
        for (i in 0 until adapter.itemCount) {
            mutableSelectedIndexes.add(i)
        }
        notifyItemsChanged()
    }

    //反选
    override fun invertSelected() {
        val selectStates = BooleanArray(adapter.itemCount) { false }
        mutableSelectedIndexes.forEach {
            selectStates[it] = true
        }
        mutableSelectedIndexes.clear()
        selectStates.forEachIndexed { index, select ->
            if (!select) {
                mutableSelectedIndexes.add(index)
            }
        }
        notifyItemsChanged()
    }
}

标识模式

存储选中状态从标识变成了标识集合

class SingleSelectModuleByKey<I : Any> internal constructor(
    override val adapter: MultiTypeBindingAdapter<I, *>,
    val selector: I.() -> Any,
)  {
    private val mutableSelectedItems: MutableMap<Any, IndexedValue<I>> = HashMap()
    override fun isSelected(selectKey: I): Boolean {
        return mutableSelectedItems.containsKey(selectKey.selector())
    }
    override fun selectItem(selectKey: I, choose: Boolean) {
        val id = selectKey.selector()
        if (choose) {
            mutableSelectedItems[id] = IndexedValue(selectKey)
        } else {
            mutableSelectedItems.remove(id)
        }
        notifyItemsChanged()
    }
    //全选
    override fun selectAll() {
        mutableSelectedItems.clear()
        mutableSelectedItems.putAll(adapter.data.mapIndexed { index, it ->
            it.selector() to IndexedValue(it, index)
        })
        notifyItemsChanged()
    }
    //反选
    override fun invertSelected() {
        val other = adapter.data
            .asSequence()
            .filter { it !in mutableSelectedItems }
            .mapIndexed { index, it -> it.selector() to IndexedValue(it, index) }
            .toList()

        mutableSelectedItems.clear()
        mutableSelectedItems.putAll(other)

        notifyItemsChanged()
    }
}

使用上也是类似的

val selectModule = dataAdapter.setupMultiSelectModule()
val selectModule = dataAdapter.setupMultiSelectModuleByKey()

总结

本文实现了在RecyclerView中使用的独立的单选,多选模块,有下标模式标识模式基本能满足项目中的需求。 利用BindingAdapter提供的接口,使得添加选择模块几乎是拔插式的。 同时,由于RadioGroupTabLayout更新数据麻烦,需要重写removeadd。因此许多情况下RecyclerView也可以代替RadioGroupTabLayout使用

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值