Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет. На днях столкнулся с проблемой реализации выбора нескольких элементов в RecyclerView с использованием dataBinding'а.
Сразу за дело
Для начала напишем базовый адаптер, поддерживающий dataBinding.
/**
* Универсальный адаптер для data binding'а.
* @param layoutRes id layout'а, который будет установлен для итемов
* @param lifecycleOwner lifecycle owner фрагмента или активти, в котором лежит recycler view
* @param itemBindingId id переменной в layout'е, в это поле устанавливается итем
* @param onClick метод, вызываемый при клике на итем
*/
class RecyclerViewAdapter<Item : IRecyclerViewItem>(
@LayoutRes private val layoutRes: Int,
private val lifecycleOwner: LifecycleOwner,
private val itemBindingId: Int? = null,
private val onClick: ((Item) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerViewAdapter<Item>.ViewHolder>() {
private val items = mutableListOf<Item>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
//Создаем базовый ViewDataBinding экземпляр с переданным layoutRes
val binding = DataBindingUtil.inflate<ViewDataBinding>(inflater, layoutRes, parent, false)
return ViewHolder(binding)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
//По сути просто вызываем onBind с текущим итемом
val item = items[position]
holder.onBind(item)
}
/**
* Установка итемов в адаптер
*
* @param newItems новыйе итемы
*/
fun setItems(newItems: List<Item>) {
val diffUtilCallback = DiffUtilCallback(newItems)
val diffResult = DiffUtil.calculateDiff(diffUtilCallback)
items.apply {
clear()
addAll(newItems)
}
diffResult.dispatchUpdatesTo(this)
}
//Тут происходит вся магия DataBinding'а
inner class ViewHolder(
private val binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Item) {
binding.apply {
//Установка переменных
setVariable(itemBindingId ?: BR.item, item)
root.setOnClickListener { onClick?.invoke(item) }
lifecycleOwner = this@RecyclerViewAdapter.lifecycleOwner
}
}
}
private inner class DiffUtilCallback(private val newItems: List<Item>) : DiffUtil.Callback() {
override fun getOldListSize(): Int = itemCount
override fun getNewListSize(): Int = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return newItems[newItemPosition].id == items[oldItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return newItems[newItemPosition] == items[oldItemPosition]
}
}
}
Так же для работы DiffUtil я сделал интерфейс, который показывает, что элемент имеет уникальное поле
/**
* Интерфейс для ui моделей, необходим для RecyclerViewAdapter.
* @property id уникальное поле итема
*/
interface IRecyclerViewItem {
val id: Int
}
До недавнего времени, данный адаптер позволял решить почти все задачи со списками. В зависимости от проекта onClick можно заменить на onBind: (binding: ViewDataBinding) -> Unit, тем самым можно сделать настройку отдельных элементов итема.
Selection helper
Настало время магии, пора писать сам SelectionHelper, который будет работать для dataBinding'а и иметь высокую производительность.
За время написания возможности выбора элементов из списка, была создана куча костылей, которые обладали очень медленным перфомансом при поиске выбранных элементов, или же код реализации был кривым.
Вот самый лучший, на мой взгляд, вариант:
class SelectionHelper<T : IRecyclerViewItem> : ISelectionHelper<T>() {
//Мапа со всеми выбранными элементами
private val selectedItems = mutableMapOf<Int, T>()
//Обработка итема, если он уже выбран - убираем его, иначе - наоборот
override fun handleItem(item: T) {
if (selectedItems[item.id] == null) {
selectedItems[item.id] = item
} else {
selectedItems.remove(item.id)
}
//Уведомляем dataBining, что пора бы обновить ui)
notifyChange()
}
override fun isSelected(id: Int): Boolean = selectedItems.containsKey(id)
override fun getSelectedItems(): List<T> = selectedItems.values.toList()
override fun getSelectedItemsSize(): Int = selectedItems.size
}
// Наследуем класс от BaseObservable, для того, что бы dataBinding мог следить за
// изменением сотояния хелпера
abstract class ISelectionHelper<T : IRecyclerViewItem> : BaseObservable() {
abstract fun handleItem(item: T)
abstract fun isSelected(id: Int): Boolean
abstract fun getSelectedItems(): List<T>
abstract fun getSelectedItemsSize(): Int
}
Из преимуществ такого подхода можно выделить:
Со стороны viewModel мы можем иметь быстрый доступ к выбранным элементам через selectionHelper.getSelectedItems, при надобности.
Возможность использовать DataBinding, без надобности как-то уведомлять adapter о изменении состояния итема
Выделение можно делать как под копотом адаптера, так и настраивать все через тот же самый onBind
Теперь для работы с таким хелпером нам надо:
Создать сам хелпер в viewModel/presenter или где угодно, где он нужен
Передать его в адаптер
Модифицировать xml итема
С первым пунктом не должно быть каких-либо проблем, а вот вторым мы сейчас и займемся
Переписываем adadpter
class RecyclerViewAdapter<Item : IRecyclerViewItem>(
@LayoutRes private val layoutRes: Int,
private val lifecycleOwner: LifecycleOwner,
private val itemBindingId: Int? = null,
//Делаем его нулабельным, что бы не поломать логику, когда нам не нужно выделение
private val selectionHelper: ISelectionHelper<Item>? = null,
private val onClick: ((Item) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerViewAdapter<Item>.ViewHolder>() {
...
inner class ViewHolder(
private val binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Item) {
binding.apply {
//Установка переменных
setVariable(itemBindingId ?: BR.item, item)
selectionHelper?.let { setVariable(BR.selectionHelper, it) }
root.setOnClickListener {
//Вызываем обработку элемента
selectionHelper?.handleItem(item)
onClick?.invoke(item)
}
lifecycleOwner = this@RecyclerViewAdapter.lifecycleOwner
}
}
}
}
Сейчас при каждом клике элемент будет менять свое состояние выбран/не выбран, это поведение можно поменять, сделав метод onBind, или же как-либо по другому.
Весь код адаптера
class RecyclerViewAdapter<Item : IRecyclerViewItem>(
@LayoutRes private val layoutRes: Int,
private val lifecycleOwner: LifecycleOwner,
private val itemBindingId: Int? = null,
private val selectionHelper: ISelectionHelper<Item>? = null,
private val onClick: ((Item) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerViewAdapter<Item>.ViewHolder>() {
private val items = mutableListOf<Item>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = DataBindingUtil.inflate<ViewDataBinding>(inflater, layoutRes, parent, false)
return ViewHolder(binding)
}
override fun getItemCount(): Int = items.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.onBind(item)
}
/**
* Установка итемов в адаптер
*
* @param newItems новыйе итемы
*/
fun setItems(newItems: List<Item>) {
val diffUtilCallback = DiffUtilCallback(newItems)
val diffResult = DiffUtil.calculateDiff(diffUtilCallback)
items.apply {
clear()
addAll(newItems)
}
diffResult.dispatchUpdatesTo(this)
}
inner class ViewHolder(
private val binding: ViewDataBinding
) : RecyclerView.ViewHolder(binding.root) {
fun onBind(item: Item) {
binding.apply {
//Установка переменных
setVariable(itemBindingId ?: BR.item, item)
selectionHelper?.let { setVariable(BR.selectionHelper, it) }
root.setOnClickListener {
//Вызываем обработку элемента
selectionHelper?.handleItem(item)
onClick?.invoke(item)
}
lifecycleOwner = this@RecyclerViewAdapter.lifecycleOwner
}
}
}
private inner class DiffUtilCallback(private val newItems: List<Item>) : DiffUtil.Callback() {
override fun getOldListSize(): Int = itemCount
override fun getNewListSize(): Int = newItems.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return newItems[newItemPosition].id == items[oldItemPosition].id
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return newItems[newItemPosition] == items[oldItemPosition]
}
}
}
Модифицируем наш итем
И так, пришло время немного переписать xml итема, добавляем наш selectionHelper как пременную в xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="item"
type="ImageItemUi" />
<!-- А вот и он) -->
<variable
name="selectionHelper"
type="dev.syncended.ctime.utils.ui.ISelectionHelper<ImageItemUi>" />
<import type="dev.syncended.ctime.models.ui.ImageItemUi" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/ui_spacing_normal"
android:padding="@dimen/1dp"
android:scaleType="centerCrop"
app:file="@{item.file}"
app:item_id="@{item.id}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
app:selection_helper="@{selectionHelper}"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Сейчас для выделения я сделал padding = 1dp, для того, что бы менять фон выделенного изображения, по факту отображение выделения зависит только от вашей фантазии.
Добавляем новый bindingAdapter для обработки изменений в selectionHelper
//Обработка изменений selectionHelper'а, для этого нам нужен id итема
@BindingAdapter("selection_helper", "item_id", requireAll = true)
fun <T : IRecyclerViewItem> handleSelection(
view: View,
selectionHelper: ISelectionHelper<T>,
itemId: Int
) {
//Смотрим текущее состояние итема
val isSelected = selectionHelper.isSelected(itemId)
//Выбираем цвет в зависимости от состояния
val color = if (isSelected) {
R.color.color_primary
} else {
android.R.color.transparent
}
view.setBackgroundColor(ContextCompat.getColor(view.context, color))
}
Таким вот образом, если элемент выбран мы меняем ему background.
Результат
Вот список элементов до клика по ним:
После кликов получаем вот такой результат:
Заключение
По результату мы получили довольно простой инструмент для выделения элементов списка. Если отойти от обычного выделения рамкой, можно будет менять состояние, допустим, чекбокса, в зависимости от того, выбран элемент или нет.
android:checked=@{selectionHelper.isSelected(item.id)}
По аналогии можно сделать кучу разных вариаций использования данного хэлепера.
Спасибо за прочтение, это моя первая статья, так что не судите строго, а так же держите котика.