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

前言

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

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

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

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

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

效果

import me.lwb.adapter.select.isItemSelectedclass XxxActivity {private val dataAdapter =BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->itemBinding.tips.text = itemitemBinding.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: Intvar enableUnselect: Booleaninit {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: SingleSelectModuleval adapter =BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { pos, item ->val isItemSelected = selectModule.isSelected(pos)itemBinding.tips.text = itemitemBinding.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 = itemitemBinding.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)}}}
}

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

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

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

var BindingViewHolder<*>.isItemSelected: Booleanset(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.isItemSelected3class XxActivity {private val dataAdapter =BindingAdapter(ItemTestBinding::inflate, TestData.stringList()) { _, item ->//使用isItemSelected isItemSelected2 isItemSelected3itemBinding.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 = selectedItemreturn 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

本文链接:https://my.lmcjl.com/post/12574.html

展开阅读全文

4 评论

留下您的评论.