Стандартная анимация RecyclerView без моргания

RecyclerView – достаточно мощный и популярный инструмент среди Android разработчиков, и многим при использовании его в своих приложениях приходилось искать ответ на вопрос “Как избавиться от моргания при изменении элемента в RecyclerView”. Если вы уже разобрались в этом вопросе, то здесь скорее всего не узнаете ничего нового, однако для новичка эта статья может оказаться полезной.

Базовое использование RecyclerView

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

fun setData(newDataset: List<RecyclerItem>) {
    myDataset.clear()
    myDataset.addAll(newDataset)
    notifyDataSetChanged()
}

Мы в адаптере объявляем функцию, в которой заменяем старые элементы на новые, и вызываем notifyDataSetChanged() для информирования адаптера об изменениях. Хоть этот способ самый простой, но он и самый затратный, так как перерисовываются все элементы. И выглядит он недостаточно красиво из-за того, что на месте старых элементов появляются новые без какой-либо анимации.

Избавление от notifyDataSetChanged()

Если вы хотите сделать обновление списка красивым, то необходимо избавиться от notifyDataSetChanged() и использовать следующие методы для оповещения адаптера об изменениях.

fun notifyItemMoved(fromPosition: Int, toPosition: Int)

fun notifyItemInserted(position: Int)
fun notifyItemRangeInserted(startPosition: Int, itemCount: Int)

fun notifyItemRemoved(position: Int)
fun notifyItemRangeRemoved(startPosition: Int, itemCount: Int)

fun notifyItemChanged(position: Int)
fun notifyItemRangeChanged(startPosition: Int, itemCount: Int)

Что они делают можно понять из их названия или посмотреть документацию. И если изначально мы сообщали, что какие-то данные изменились и необходимо обновить всё, то теперь мы можем указывать, что именно изменилось в нашем списке. Плюсом данного подхода является то, что в RecyclerView есть стандартные анимации для данных операций, и если мы, например, удаляем элемент, то он будет исчезать с затуханием, если меняем позицию элемента в списке, то он перемещается и на экране, и тд.

Если у нас приложение простое и данные обновляются предсказуемо, то мы можем вручную вызывать эти методы. Например, при изменении одного элемента или добавлении нескольких это можно записать так:

fun updateItem(newItem: RecyclerItem) {
    for (i in 0..myDataset.size) {
        if (myDataset[i].id == newItem.id) {
            myDataset[i] = newItem
            notifyItemChanged(i)
            break
        }
    }
}

fun addItem(newItems: List<RecyclerItem>) {
    val oldSize = myDataset.size
    myDataset.addAll(newItems)
    notifyItemRangeInserted(oldSize, myDataset.size)
}

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

Функции getOldListSize() и getNewListSize() достаточно просты, и должны просто возвращать размеры старого и нового списков, которые вы передаете в конструкторе.

class MyDiffUtilCallback(
    private val oldList: List<RecyclerItemModel>,
    private val newList: List<RecyclerItemModel>
) : DiffUtil.Callback() {

    override fun getOldListSize() = oldList.size

    override fun getNewListSize() = newList.size

В areItemsTheSame(oldPos: Int, newPos: Int) нужно проверять, является ли элемент из нового списка с позицией newPos тем же элементом в старом списке в позицией oldPos. Обычно здесь сравнивают какой-либо уникальный идентификатор, чтобы не тратить время на сравнение всех полей, если элементы заведомо разные.

override fun areItemsTheSame(oldPos: Int, newPos: Int) =
        oldList[oldPos].id == newList[newPos].id

Метод areContentsTheSame(oldPos: Int, newPos: Int) вызывается только в том случае, если в areItemsTheSame(oldPos: Int, newPos: Int) вернулось true. Это значит, что под этими позициями лежит один и тот же элемент, и теперь необходимо проверить, изменилось ли что-нибудь внутри элемента. Здесь можно сравнить либо все поля класса, либо только те, которые влияют на UI часть.

override fun areContentsTheSame(oldPos: Int, newPos: Int) = oldList[oldPos] == newList[newPos]

После того как мы реализовали свою версию DiffUtil.Callback, необходимо вычислить результат преобразования списков и доставить этот результат в адаптер. Сделать это можно следующим образом:

fun setData(newDataset: List<RecyclerItemModel>) {
    val diffUtilCallback = MyDiffUtilCallback(myDataset, newDataset)
    val diffResult = DiffUtil.calculateDiff(diffUtilCallback, true)
    diffResult.dispatchUpdatesTo(this)
    myDataset.clear()
    myDataset.addAll(newDataset)
}

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

Доведение до идеала

Теперь мы имеем красивую анимацию для добавления, удаления и перемещения элементов, однако при изменении видно неприятное моргание, которое происходит из-за того, что старый элемент исчезает с fade out, а новый появляется с fade in. Для того чтобы избавиться от моргания, обычно добавляют строку, которая отключает анимацию при изменение элемента, но оставляет для остальных случаев:

(recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false

После этого мы получаем следующий результат:

Многие заканчивают либо на этом, либо на предыдущем этапе, выбирая между отсутствием анимации изменения и плохой её реализацией. Однако если вы хотите, чтобы разные UI части вашего элемента изменялись по-разному, то для этого существуют payloads — с помощью них вы можете доставлять адаптеру информацию не только о том, что элемент изменился, а также что именно изменилось. Для того чтобы это сделать, существуют два метода:

fun notifyItemChanged(position: Int, payload: Any)
fun notifyItemRangeChanged(startPosition: Int, itemCount: Int, payload: Any)

Вместе с позицией мы можем передать дополнительную информацию, и так как в сигнатуре метода стоит тип Any, то сделать это мы можем различными способами, например, можно передавать только маркер, означающий, что изменилось какое-то поле, можно дополнительно передавать новое значение, либо новое и старое, и тд.

Приведу пример составления payloads с помощью bundle, который хранит новое значение:

const val IMAGE_PAYLOAD = "IMAGE_PAYLOAD"
const val TITLE_PAYLOAD = "TITLE_PAYLOAD"
const val CHECKED_PAYLOAD = "CHECKED_PAYLOAD"

fun generatePayload(oldItem: RecyclerItem, newItem: RecyclerItem): Bundle? {
    val bundle = Bundle()
    if (oldItem.imageId != newItem.imageId) {
        bundle.putInt(IMAGE_PAYLOAD, newItem.imageId)
    }
    if (oldItem.title != newItem.title) {
        bundle.putString(TITLE_PAYLOAD, newItem.title)
    }
    if (oldItem.checked != newItem.checked) {
        bundle.putBoolean(CHECKED_PAYLOAD, newItem.checked)
    }
    if (bundle.isEmpty) {
        return null
    }
    return bundle
}

Если мы управляем изменениями вручную, то использовать это можно так:

fun updateItem(newItem: RecyclerItem) {
    for (i in 0..myDataset.size) {
        if (myDataset[i].id == newItem.id) {
            val oldItem = myDataset[i]
            myDataset[i] = newItem
            notifyItemChanged(i, generatePayload(oldItem, newItem))
            break
        }
    }
}

При использовании DiffUtil, помимо тех методов, которые я описал выше, есть еще метод getChangePayload(oldPos: Int, newPos: Int), который вызывается, только если в areContentsTheSame(oldPos: Int, newPos: Int) вернулось true. Это значит, что мы нашли один и тот же элемент, но с разным содержимым, и в этом методе необходимо вернуть, что именно изменилось, то есть просто вызывать наш метод generatePayload().

override fun getChangePayload(oldPos: Int, newPos: Int) =
        generatePayload(oldList[oldPos], newList[newPos])

Итак, мы передали в адаптер дополнительную информацию, но ее ещё нужно обработать. Как известно, для правильной работы адаптера нам необходимо переопределять метод onBindViewHolder(holder: MyViewHolder, position: Int), в котором мы и обновляем наш ViewHolder

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.setItem(myDataset[position])
}

Но дополнительно мы можем переопределить этот метод с еще одним параметром

override fun onBindViewHolder(holder: MyViewHolder,
                              position: Int,
                              payloads: MutableList<Any>) {
    if (payloads.isEmpty()) {
        onBindViewHolder(holder, position)
    } else {
        holder.updateItem(myDataset[position], payloads.last() as Bundle)
    }
}

Третьим параметром приходят payloads, которые мы отправляли ранее. Если этот список пустой, то вызываем обычную версию onBindViewHolder(holder: MyViewHolder, position: Int). Так как обработка payloads происходит не сразу, то может произойти несколько вызовов notifyItemChanged(position: Int, payload: Any), и в итоге мы получим не один payload, а список. Его можно обрабатывать разными способами, например, можно применять все изменения, либо брать только последнее. Также можно в payload передавать старое и новое значения и обновлять UI только в том случае, если есть различия между старым значением у первого элемента списка и новым значением у последнего.

В классе MyViewHolder мы имеем два основных метода для обновления. В первом мы просто проставляем значения в различные View, ничего не анимируя, иначе при создании и переиспользовании холдера пользователь будет видеть анимацию изменения, хотя ничего не поменялось.

fun setItem(item: RecyclerItem) {
    image.setImageDrawable(ContextCompat.getDrawable(image.context, item.imageId))
    titleTextView.text = item.title
    checkedImageView.visibility = if (item.checked) View.VISIBLE else View.GONE
}

А вот во втором методе, используя наш payload, мы уже реализуем ту анимацию, которая нам нравится. Например, заголовок и картинку будем менять без анимации, так как это кажется излишним, а вот галочку можно показывать и скрывать, изменяя прозрачность.

fun updateItem(item: RecyclerItem, bundle: Bundle) {
    if (bundle.containsKey(MyDiffUtilCallback.IMAGE_PAYLOAD)) {
        image.setImageDrawable(ContextCompat.getDrawable(image.context, item.imageId))
    }
    if (bundle.containsKey(MyDiffUtilCallback.TITLE_PAYLOAD)) {
        titleTextView.text = bundle.getString(MyDiffUtilCallback.TITLE_PAYLOAD)
    }
    if (bundle.containsKey(MyDiffUtilCallback.CHECKED_PAYLOAD)) {
        if (item.checked) {
            checkedImageView.animate().alpha(1f)
                .withStartAction { checkedImageView.visibility = View.VISIBLE }
        } else {
            checkedImageView.animate().alpha(0f)
                .withEndAction { checkedImageView.visibility = View.GONE }
        }
    }
}

В итоге мы получаем обновление списка со стандартной анимацией для вставки, удаления и перемещения элементов, и с кастомной анимацией для изменения. И самое главное – никакого моргания ;)

Источник: Cтандартная анимация RecyclerView без моргания