作者:丨小夕
前言
需求很简单也很常见,比如有一个数据列表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个数据会自动取消当前已经选中的,也就是最多选中1个。
- 再次点击已经选中的元素取消选中(可配置)。
根据记录选中数据的不同,可以分为下标模式和标识模式,他们各有优缺点。
下标模式
通常情况我们都会这样实现。使用一个记录选中下标的变量selectIndex
去标识当前选择,selectIndex=-1
表示没有选中任何元素。
原理虽然简单,那么问题来了,变量selectIndex
应该放在哪里呢? Adapter
?Fragment
?Activity
?
往往许多人都会选择放在Adapter
,觉得数据选中和数据放一起嘛。
实现是实现了,但是往往有更多问题:
- 给一个列表增加数据选择功能,需要改造
Adapter
,侵入性强。 - 我要给另外一个列表增加数据选择功能,需要再实现一遍,难复用。
- 去除数据选择功能,又需要再改动
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
提供的接口,使得添加选择模块几乎是拔插式的。 同时,由于RadioGroup
、TabLayout
更新数据麻烦,需要重写remove
,add
。因此许多情况下RecyclerView
也可以代替RadioGroup
、TabLayout
使用
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