Работа со списками в Android проектах — это база. Большинство проектов использует RecyclerView из-за его гибкой настройки и переиспользования ViewHolder'ов. Но даже так существуют библиотеки, которые улучшают работу с RecyclerView.Adapter и RecyclerView.ViewHolder с более удобной компоновкой большого числа элементов списка.

Писать свою библиотеку это увлекательно и познавательно. Ты одновременно решаешь свои задачи и глубже познаёшь инструменты, которыми пользуешься. Всем советую.

Предлагаю вашему вниманию библиотеку, построенную на использование ViewBinding, которая решает мои задачи. Если не используете Compose, то скорее всего включён viewBinding, плюсы которого уже много раз расписывали. Поэтому не буду на нём останавливаться

Проблематика/хотелки:

  1. Хочу один адаптер для всех списков, в экземпляры которого буду только передавать ViewHolder'ы

  2. Калькуляции для обновления списка в фоновом потоке; маст хев для любой библиотеки

  3. Не передавать отдельно каждый раз DiffUtil.ItemCallback

  4. Улучшить работу с payload'ами

И вот он SingleRecyclerAdapter, единственный адаптер, экземпляры которого вы можете передавать в recycle вот так

   override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        with(recycler) {
            adapter = binderAdapterOf(
                HeaderUiModel::class bindWith HeaderViewHolderFactory(),
                GroupUiModel::class bindWith GroupViewHolderFactory(
                    action = { title ->
                        Toast.makeText(context, "Clicked $title", Toast.LENGTH_SHORT).show()
                    }
                )
           )
           setBindingList(dataFactory.createGroups())
        }
    }

binderAdapterOf — функция создания ArrayMap
bindWith — используется для более удачных подсказок ide; эквивалент существующего инфикса to для создания Pair.

Как установить список?

Рассмотри UiModel'и, это моделька реализующая интерфейс BindingClass.
Как видите, мы используем ui модели как ключ, чтобы потом получить ViewHolderFactory, которая создаст нам ViewHolder. Но не передаём колбек для обработки нашего списка.

А всё потому, что есть одна единственная реализация.

internal class BindingDiffUtilItemCallback : DiffUtil.ItemCallback<BindingClass>() {

    override fun areItemsTheSame(oldItem: BindingClass, newItem: BindingClass): Boolean =
        oldItem.areItemsTheSame(newItem)

    override fun areContentsTheSame(
        oldItem: BindingClass,
        newItem: BindingClass
    ): Boolean = oldItem.areContentsTheSame(newItem)

    override fun getChangePayload(
        oldItem: BindingClass,
        newItem: BindingClass
    ): Any = oldItem.getChangePayload(newItem)
}

Колбек проксирует одноимённые методы из BindingClass. В результате, появляется возможность изменять логику проверок в самих модельках, реализующих интерфейс.

interface BindingClass {

    val itemId: Long
        get() = this.hashCode().toLong()

    fun areContentsTheSame(other: BindingClass): Boolean = other == this

    fun areItemsTheSame(other: BindingClass): Boolean = (other as? BindingClass)?.itemId == itemId

    fun getChangePayload(newItem: BindingClass): List<Any> = listOf()
}

А как вынести обновления списка в другой поток и зачем это нужно?

Это хорошая практика, особенно выручает, когда у вас очень сложный перегруженный экран и потерять фреймы при отрисовке очень легко. От чего плавность работы экрана будет страдать. Пользователи будут расстраиваться и реже заходить в наше приложение, если не удалят его.

Ничего нового не придумал и использовал AsyncListDiffer, у которого есть метод submitList, который сам уведомит адаптер, когда закончит расчёты.

class SingleRecyclerAdapter(
    private val factory: ArrayMap<KClass<out BindingClass>, ViewHolderFactory<ViewBinding, BindingClass>>
) : RecyclerView.Adapter<BindingViewHolder<BindingClass, ViewBinding>>() {

    private val items
        get() = differ.currentList
    private var differ: AsyncListDiffer<BindingClass> = AsyncListDiffer(
        this@SingleRecyclerAdapter,
        BindingDiffUtilItemCallback()
    )
    ...
    fun setItems(items: List<BindingClass>) = differ.submitList(items)
    ...

Особенности работы с Payload

Полезная вещь, ведь мы не хотим каждый раз отрисовывать полностью элемент списка, когда происходит изменение только его части. Например, прожатие лайка. Это выливается в лишнюю работу и неприятное мерцание, которое можно перебить лоадером на весь экран.
Поэтому в DiffUtil.ItemCallback есть третий метод getChangePayload , в котором мы конкретизируем изменения.

Если вы пишите свой ViewHolder сами, то у вас есть перегрузки метода onBindViewHolder с payload и без. В моей библиотеке такого удовольствия не будет.

Плюсом такого решения является:

  • Нет необходимости метаться между двумя
    функциями

  • Нелишнее напоминание об присутствии payloads

  • Удобно, так как большая необходимость в использовании. В коммерческой разработке вам чаще нужна полезная нагрузка чем не нужна.

А вот и примеры:

Модель для отрисовки вложенного списка

data class GroupUiModel(
    val title: String,
    val items: List<InnerItemUiModel>
) : BindingClass {
    // Как пример, решил, что если заголовок тот же самый
    // значит используется та же самая группа
    // поэтому использовал как itemId
    // Чтобы при измение вложенного списка,
    // у нас появлялось payload
    override val itemId: Long = title.hashCode().toLong()

    override fun getChangePayload(newItem: BindingClass): List<GroupPayload> {
        val item = newItem as? GroupUiModel
        // Функция создаёт список,
        // фильтрую мапу по значенния, равным true
        // возвращая ключи мапы
        return checkChanges(
            mapOf(
                GroupPayload.ItemsChanged to (items != item?.items)
            )
        )
    }
}

sealed class GroupPayload {
    object ItemsChanged : GroupPayload()
}

ViewHolderFactory с вложенным списком

class GroupViewHolderFactory(
    private val action: (title: String) -> Unit
) : ViewHolderFactory<RecyclerItemGroupBinding, GroupUiModel> {

    override fun create(
        parent: ViewGroup
    ) = BindingViewHolder<GroupUiModel, RecyclerItemGroupBinding>(
        RecyclerItemGroupBinding.inflate(
            LayoutInflater.from(parent.context),
            parent,
            false
        )
    ).apply {
        with(binding) {
            root.setOnClickListener {
                action(item.title)
            }

            recycler.adapter = binderAdapterOf(
                InnerItemUiModel::class bindWith InnerGroupViewHolderFactory()
            )
        }
    }

    override fun bind(
        binding: RecyclerItemGroupBinding,
        model: GroupUiModel,
        payloads: List<Any>
    ) = when {
        payloads.isNotEmpty() -> payloads.check { payload ->
            when (payload) {
                GroupPayload.ItemsChanged -> binding.recycler.setBindingList(model.items)
            }
        }
        else -> with(binding) {
            recycler.setBindingList(model.items)
            title.text = model.title
        }
    }
}

Функция расширения List<Any>.check на самом деле очень полезная и лично мне помогает расслабить мозг.

Дело в том, что если по какой-то причине произошло несколько быстрых обновлений элемента списка. То в bind может положиться несколько полезных нагрузок подряд(а может и не положится и будет второй вызов bind) и нужно их все не забыть обработать.

Ещё ситуация, вы передаёте в списке список полезных нагрузок, то есть

payloads: List<Any> == listOf( listOf(Payload.Liked, Payload.AddToFavorites) )

И чтобы не задумываться каждый раз, что же приходит, нужно лишь вызвать функцию и написать обработчик payload через when. Это успех, это победа.

Конец

Вот ссылка на библиотеку, где можно посмотреть sample со списком в списке.

Всем спасибо за внимание и ознакомление с возможностями библиотеки.

Буду рад любым комментариям.

Комментарии (4)


  1. Rusrst
    19.04.2023 19:11

    В interface BindingClass лучше сразу добавить override equals(item:Any) и override hashCode(item:Any), в дата классах они автоматом подхватятся, а если будет обычный класс, интерфейс заставит реализовать.


    1. markdaniLov4986
      19.04.2023 19:11

      Полностью с Вами согласен, ведь с этими методами будет намного корректнее работать сравнение объектов и использование их в коллекциях, основанных на хэш-таблицах. Кроме того, добавление этих методов в интерфейс поможет обеспечить единообразие в использовании интерфейса, даже если он будет реализован обычным классом.


      1. AlexDeko Автор
        19.04.2023 19:11

        Ответил в корневом комментарии.


    1. AlexDeko Автор
      19.04.2023 19:11

      Спасибо за предложение. Пока что не вижу потребности в добавление здесь и сейчас этих методов, кроме как напоминалки об добавление ключевого слова data к классу. Скорее всего, в следующей версии добавлю equals.

      Насчёт hashCode, в ArrayMap хешируется ключ, а ключом у нас выступает KClass, уникальность хеша которого не зависит от data class.

      И equals не обязательно добавлять, ведь можно использовать методы, которые уже есть в BindingClass, например, прямой аналог функцияareContentsTheSame (если не хотите использовать data class), которую можно переопределить и сравнить нужные параметры класса. Хотя напоминалка действительно удобная могла бы получиться с equals, чтобы выжать максимум из использования дефолтной реализации areContentsTheSame .