注册

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

前言


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


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


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


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



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

项目地址 BindingAdapter


效果


img1.jpg
img5.jpg
img4.jpg
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提供的接口,使得添加选择模块几乎是拔插式的。
同时,由于RadioGr0upTabLayout更新数据麻烦,需要重写removeadd。因此许多情况下RecyclerView也可以代替RadioGr0upTabLayout使用


本文的实现和Demo均可在项目中找到。


项目地址 BindingAdapter


作者:丨小夕
来源:juejin.cn/post/7246657502842077245

0 个评论

要回复文章请先登录注册