Всем привет. На днях столкнулся с проблемой реализации выбора нескольких элементов в 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

Теперь для работы с таким хелпером нам надо:

  1. Создать сам хелпер в viewModel/presenter или где угодно, где он нужен

  2. Передать его в адаптер

  3. Модифицировать 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&lt;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)}

По аналогии можно сделать кучу разных вариаций использования данного хэлепера.

Спасибо за прочтение, это моя первая статья, так что не судите строго, а так же держите котика.