You have already learned about different RecyclerView components, such as LayoutManager, ItemDecoration, and ItemAnimator. Now it's time to focus on the Adapter, which is very interesting in its own right.
Partial updates
The purpose of the notifyItemChanged / notifyItemRangeChanged family of methods is simple: they let the Adapter know about changes so that it can rebind items. ItemAnimator can then animate these changes inside a RecyclerView. However, these methods also have some special overloads involving the payload parameter:
notifyItemChanged(position: Int, payload: Any?)notifyItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?)
The change payload serves as a message that describes what has changed inside an item. When payload == null, a full update occurs. So the old ViewHolder gets crossfaded with a new, freshly bound one (assuming the default item animator is being used).
However, if the payload isn't null, it gets passed to the onBindViewHolder(…, …, payloads: List<Any>) method, and the default item animator does nothing. This means we can pass anything except null as a payload to bypass change animation altogether (notifyItemChanged(0, Unit), for example). And if we take this approach, items will be updated immediately, without the blinking caused by crossfade animation.
When using DiffUtil, this can be achieved by overriding the getChangePayload method:
object : DiffUtil.ItemCallback<Transaction>() {
override fun areItemsTheSame(
oldItem: Transaction, newItem: Transaction,
): Boolean =
// ...
override fun areContentsTheSame(
oldItem: Transaction, newItem: Transaction,
): Boolean =
oldItem == newItem
override fun getChangePayload( // added
oldItem: Transaction, newItem: Transaction,
): Any? =
Unit
}
However, the primary purpose of change payloads is to deliver some useful information to onBindViewHolder. This method has two overloads, so to take advantage of payloads, we need to override the relevant one:
override fun onBindViewHolder(holder: TransactionViewHolder, position: Int) {
// we have to override it because it's abstract…
}
override fun onBindViewHolder(
holder: TransactionViewHolder, position: Int, payloads: List<Any>,
) {
// but we're going to use this one in order to receive payloads!
}
That gives us a list of payloads. It will be empty in case of a full update; otherwise, it will contain all the values passed to the notify method since the last binding.
notifyItemChanged, getChangePayload can give you only a single payload object. If you return a collection, payloads list will contain this collection, not its items. Consider this indirection when handling payloads.
Let's return to the bank application example used in earlier RecyclerView topics. Imagine an alternative banking system where several transaction attributes may change while it is being processed:
- receiver can redirect it to their partner or agent,
- amount can be slightly decreased if it turns out that the transaction gets processed inside one bank and one country,
- status can change from PROCESSING to FAILED or SUCCESSFUL.
Thus, the Transaction class is going to look like this:
data class Transaction(
val id: Int,
val receiver: String,
val account: String,
val amount: Long,
val status: Status,
val time: Instant,
) {
enum class Status {
PROCESSING, SUCCESSFUL, FAILED,
}
}
To keep track of property changes, we need to enumerate them:
enum class ChangeField {
RECEIVER, AMOUNT, STATUS,
}
Payload is going to list all changed properties:
object : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Transaction, newItem: Transaction): Boolean =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Transaction, newItem: Transaction): Boolean =
oldItem == newItem
override fun getChangePayload(oldItem: Transaction, newItem: Transaction): Any? =
listOfNotNull(
ChangeField.RECEIVER.takeIf { oldItem.receiver != newItem.receiver },
ChangeField.AMOUNT.takeIf { oldItem.amount != newItem.amount },
ChangeField.STATUS.takeIf { oldItem.status != newItem.status },
)
}
With that done, we can use the code shown below to apply updates, including partial ones when requested:
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
}
override fun onBindViewHolder(
holder: TransactionViewHolder, position: Int, payloads: List<Any>,
) {
val t = getItem(position)
// collect all supported payloads
val changes =
if (payloads.isEmpty()) emptySet<ChangeField>()
else EnumSet.noneOf(ChangeField::class.java).also { changes ->
payloads.forEach { payload ->
(payload as? Collection<*>)?.filterIsInstanceTo(changes)
}
}
holder.apply {
if (changes.isEmpty()) {
account.text = t.account
}
// apply changes if requested or in case of full binding
if (changes.isEmpty() || ChangeField.RECEIVER in changes) {
receiver.text = t.receiver
}
if (changes.isEmpty() || ChangeField.AMOUNT in changes) {
amount.text = "$%.2f".format(t.amount / 100f)
}
if (changes.isEmpty() || ChangeField.STATUS in changes) {
status.text = t.status.name
status.setTextColor(when (t.status) {
Item.Transaction.Status.PROCESSING -> Color.DKGRAY
Item.Transaction.Status.SUCCESSFUL -> Color.GREEN
Item.Transaction.Status.FAILED -> Color.RED
})
}
}
}
And this is how it looks:
The main purpose of payload machinery is to apply changes faster. This is very important when dealing with volatile data like real-time stock prices, which change several times a second. But in simpler scenarios, passing a dummy payload (like Unit) is a straightforward way to apply minor updates without change animation (as explained earlier in this section).
Heterogeneous adapters and view types
The dynamic lists created by RecyclerView can contain a variety of item types, and there are many ways that this can be useful. For example, we could place simple headers between items or include several completely different views in the same list. The Adapter includes the concept of view types for this purpose. But to use them, we first need to make our data heterogeneous. Let's use transactions and days as an example:
sealed class Item {
data class Transaction(
val id: Int,
val receiver: String,
val account: String,
val amount: Long,
val time: Instant,
val status: Status,
) : Item() {
// enums…
}
class Day(
val day: LocalDate,
) : Item() {
override fun equals(other: Any?): Boolean =
other is Day && day == other.day
}
}
We now need to convert our Adapter to use Item instead of Transaction and base ViewHolder instead of our TransactionHolder:
class TransactionsAdapter(transactions: List<Item>) :
ListAdapter<Item, RecyclerView.ViewHolder>(…) {
init {
submitList(transactions)
}
override fun getItemViewType(position: Int): Int =
when (getItem(position)) {
is Item.Day -> 0
is Item.Transaction -> 1
}
The purpose of our newly overridden getItemViewType method is to help the Adapter distinguish between these types. We can use any integers we like to represent view types. We'll receive these values later, when new ViewHolders are requested, and determine which one to instantiate:
override fun onCreateViewHolder(
parent: ViewGroup, viewType: Int,
): RecyclerView.ViewHolder = when (viewType) {
0 -> object : RecyclerView.ViewHolder(TextView(parent.context)) {}
1 -> TransactionViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.item_transaction, parent, false))
else -> throw AssertionError()
}
Our day holder is very simple. It's just a single TextView, so it's easier to create it on-the-fly, without a dedicated holder class and a layout file. Of course, holders can be created in any way we like — it makes no difference to RecyclerView!
The binding code will change to support these different holders, too, so we'll need to figure out which type we're currently binding. But the binding process itself is left unchanged:
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
throw UnsupportedOperationException()
// we don't need it anymore, everything happens in the other overload
}
override fun onBindViewHolder(
holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>,
) {
when (val t = getItem(position)) {
is Item.Day -> (holder.itemView as TextView).text = t.day.toString()
is Item.Transaction -> bind(holder as TransactionViewHolder, t, payloads)
}
}
private fun bind(
holder: TransactionViewHolder, t: Item.Transaction, payloads: List<Any>,
) {
// ...
The result is shown below:
The code snippets provided so far would be incomplete without one further detail: a DiffUtil.ItemCallback implementation. We need to make it handle our heterogeneous items now. The following version compares transactions in the same way as it did before, but also provides support for the new Day items:
object : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean =
when (oldItem) {
is Item.Day -> oldItem == newItem
is Item.Transaction -> newItem is Item.Transaction &&
oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean =
oldItem == newItem
override fun getChangePayload(oldItem: Item, newItem: Item): Any? =
if (oldItem is Item.Transaction && newItem is Item.Transaction) listOfNotNull(
ChangeField.RECEIVER.takeIf { oldItem.receiver != newItem.receiver },
ChangeField.AMOUNT.takeIf { oldItem.amount != newItem.amount },
ChangeField.STATUS.takeIf { oldItem.status != newItem.status },
) else null
}
And that's it! You can create any number of view types in a similar fashion.
Using ConcatAdapter
There's one more tool that can make working with heterogeneous adapters easier. ConcatAdapter acts as a meta-adapter joining several adapters' contents sequentially. The usage pattern is straightforward:
recyclerView.adapter = ConcatAdapter(firstAdapter, secondAdapter)
It uses view types under the hood but allows you to avoid filling your own adapters with them. For example, when you need a header above a list, you can create a single-item header Adapter and concatenate it with the rest: ConcatAdapter(headerAdapter, someAdapter). This will result in a better separation of concerns: the main Adapter doesn't know about the header, and vice versa, but they work well together.
You can also call addAdapter and removeAdapter on an instance of ConcatAdapter to change the Adapter set dynamically:
concatAdapter.removeAdapter(staleAdapter)
concatAdapter.addAdapter(freshAdapter)
ConcatAdapter is a relatively new feature, and RecyclerView's Adapter was initially developed without ConcatAdapter in mind. For example, ViewHolder.absoluteAdapterPosition is relative to the root Adapter, and bindingAdapterPosition is relative to its direct parent. ViewHolder.itemViewType is absolute – there's no relative counterpart, but you can simulate it by calling getItemViewType(holder.bindingAdapterPosition) on its direct parent Adapter.
Conclusion
In this topic, you've learned about several advanced features for fine-tuning the behavior of your adapters. You now know that change payloads can be used to enable partial updates. Or just for binding without animation when changes are minor and you don't want a crossfade effect to be applied. Don't forget that you can use view types to create heterogeneous adapters and that it's possible to either utilize this feature directly or take advantage of ConcatAdapter.
Let's practice now!