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

Денис Левштанов
6 min readSep 5, 2019

--

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 }
}
}
}

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

--

--