Writing a completely type-safe and multitype RecyclerView Adapter

Vadim Sukharev
7 min readMar 17, 2021
image taken from here

Disclaimer: This article is not about creating an adapter with ready-made swipeable, draggable or expandable items. Instead, it will explain how to make the adapter that provide a base for combining different items in one list. Creating these items is of developer responsibility.

Implementing RecyclerView Adapter becomes a tricky task when it comes to combining multiple view types in a single list.

What will adapter be like?

  • Type-safe and multitype
  • Written once and used for each list in a project (including DiffUtil)
  • Without the need to create additional interfaces or models-wrappers. Only those ones you really need

Tech stack

Known solutions

Brute-force

Lots of solutions suggest the implementation that looks like the following:

This implementation roughly violates the Open-Closed Principle. You always have to change adapter code as soon as new item has to be added or existing item has to be deleted. Moreover, each change is accompanied by type checks and casts.

Adapter Delegates

Hannes Dorfmann’s Adapter Delegates are great in terms of composition and open-closed principle compliance. Let’s look at the example from the library’s sample:

As can be seen, you’re still forced to make type checks and downcasting inside each delegate you create which is bad. Also, in order to combine different items in one list you need your domain models to implement the interface that does nothing but provides a common type.

Type Factory

Writing Better Adapters by Danny Preussler suggests the amazing approach that achieves type-safety by using the Visitor pattern. Let’s take a closer look: (The code is taken from the links placed in article.)

This makes the adapter pretty slight:

What are the drawbacks? Firstly, your models need to implement the Visitable interface to return their view type. Domain models can’t implement that interface as it violates the principles of the Clean Architecture so one have to create additional model for each domain one that will wrap it and implement the Visitable interface. Secondly, you can’t split into the different classes the TypesFactory interface signatures. Any type that is shown in RecyclerView forces to add a new overload in TypesFactory. Imagine how many overloads there will be in a large commercial project.

Let’s get down to writing the implementation that will take the best from these approaches.

Implementing own adapter

AnyTypeViewHolder

The main entity that is required for adapter is a view holder. It will be called AnyTypeViewHolder because it will be able to work with any type.

It has two type parameters:

  • T representing the data to be bound
  • V representing Android View Binding classes.

AdapterItem

Next, describe the model that will be used as an adapter list item:

It will wrap the data internally so you’re not required to map your data or to have it inherited. Its methods replicate the DiffUtil.ItemCallback ones so this type collections will be used in callback later.

Next stage is creating a delegate. The delegate will be responsible for view holders creation and binding, returning such parameters as item id and view type:

Being the data class, AdapterItem allows you to omit getItemHash() method that is usually used to provide object hashcode for DiffUtil.

As it manages view holder creation and binding it has the H: AnyTypeViewHolder<T, V> type parameter. The other two are the same as view holder ones.

So far so good, but not only AdapterItem will be needed, but also its metadata:

AdapterItemMetaData

What does it mean? It’s called metadata because it contains not data to be bound but a common information about AdapterItem<T> instances range of the given type T.

Parameters:

  • position — from what position the range starts
  • delegate — delegate with the help of which data get bound

Consider the example: you need to show a list of cars, then an ad block and then a progress bar as a pagination indicator. The data will be:

listOf(
AdapterItem("id-0", Car()),
AdapterItem("id-1", Car()),
AdapterItem("id-2", Car()),
AdapterItem("id-3", Ad()),
AdapterItem("id-4", Car()),
AdapterItem("id-5", Car()),
AdapterItem("id-6", Car()),
AdapterItem("id-7", ProgressItem())
)

whereas metadata will be as follows:

val carDelegate = CarDelegate()
val adDelegate = AdDelegate()
val progressDelegate = ProgressDelegate()
listOf(
AdapterItemMetaData(0, carDelegate), // describes 3 items of car from position 0
AdapterItemMetaData(3, adDelegate), // describes 1 ad block from position 3
AdapterItemMetaData(4, carDelegate), // again, describes 3 cars from position 4
AdapterItemMetaData(7, progressDelegate) // describes 1 loading item from position 7
)

That’s why this class is intentionally created separately from the AdapterItem<T>. This class will help us to determine which data at which position should be bound.

AnyTypeCollection

Let’s also delegate the data list storage and creation. We’ll design this class to be immutable:

class AnyTypeCollection private constructor(
val items: List<AdapterItem<Any>>,
val itemsMetaData: List<AdapterItemMetaData<Any, ViewBinding>>,
val positionsRanges: List<IntRange>
) {
val size: Int = items.size
}

You see the new field — positionsRanges. We’ll discuss it later.

Since the adapter has to combine different view types one have to map the data and the delegate that will bind it to a holder. How to construct all these mappings into a single list? By using the Builder pattern:

Consider the addition function implementation. At first, one have to check if previously added view type is the same as currently being added. If not, then a new one is being added to AnyTypeCollection and the new item should be added to the itemsMetaData list. And you can see that addition is impossible without making a cast and the compiler warned us about it. But this cast will be the only one. It will also be safe because you won’t have the opportunity to access any item and change its delegate after AnyTypeCollection was built. In the end, you add the AdapterItem requesting the item id.

Based on this method, you can implement the method that adds the list:

// inside AnyTypeCollection.Builder.ktfun <T : Any, V: ViewBinding, H : AnyTypeViewHolder<T, V>> add(
items: List<T>,
delegate: AnyTypeDelegate<T, V, H>
): Builder {
return apply { items.forEach { add(it, delegate) } }
}

To build AnyTypeCollection you need to do the following:

// inside AnyTypeCollection.Builder.ktfun build(): AnyTypeCollection {
val positionsRanges = with(itemsMetaData) {
zipWithNext { first, second ->
first.position until second.position
} + when {
isEmpty() -> emptyList()
else -> listOf(last().position until items.size)
}
}
return AnyTypeCollection(items, itemsMetaData, positionsRanges)
}

The positionsRanges variable is composed of each two adjacent values of the itemsMetaData collection. Metadata stores only item starting position so the last item in the itemsMetaData collection will be used to get second-to-last item positions range. That’s why the last item positions range is added manually

Based on the example above, the values will be:

(0..2, 3..3, 4..6, 7..7)

This collection will be used later in the getItemViewType() adapter method.

Accessing the appropriate delegate

In AnyTypeCollection, declare the property that will store current item view type position taken from getItemViewType() method:

var currentItemViewTypePosition: Int = 0 //in AnyTypeCollection.kt

and the delegate that can be accessed by this position:

//in AnyTypeCollection.kt
val currentItemViewTypeDelegate: AnyTypeDelegate<Any, ViewBinding, AnyTypeViewHolder<Any, ViewBinding>>
get() = itemsMetaData[currentItemViewTypePosition].delegate

This property will be used in the onCreateViewHolder() and onBindViewHolder() methods.

Putting it all together:

AnyTypeAdapter

Let’s implement the main adapter methods:

As can be seen, AnyTypeCollection and its fields came in handy. CurrentItemViewTypeDelegate is used for creation and binding view holders, size is used to return total item count.

Where is type-safety achieved? You could use in onBindViewHolder() the following code:

holder.bind(anyTypeCollection.items[position])

And get rid of the AnyTypeDelegate.bind() method. But the thing is that holder.bind() takes the Any parameter so any of these calls will be compiled:

holder.bind(anyTypeCollection.items)
holder.bind(anyTypeCollection.items[position])
holder.bind(anyTypeCollection.items[position].data)

So there is no guarantee you won’t make a mistake and avoid a runtime error.

But by using delegate you are restricted with the type parameter:

with(anyTypeCollection) {
currentItemViewTypeDelegate.bind(items[position], holder)
} // will compile
with(anyTypeCollection) {
currentItemViewTypeDelegate.bind(items[position].data, holder)
} // won't compile

Let’s take a closer look to the getItemViewType() method. It has an inner call of the findCurrentItemViewTypePosition function:

It performs a binary search in the positionsRanges list returning the index of the range inside which current adapter position is in. Then this position is used to get the appropriate delegate and item view type.

Diffing

Having AdapterItem it’s easy to implement DiffUtil.Callback:

The last thing to do is to write the function that sets new data to adapter. This function will be powered by Kotlin Coroutines so one have to declare a coroutine scope:

open class AnyTypeAdapter : RecyclerView.Adapter<AnyTypeViewHolder<Any, ViewBinding>>(),
CoroutineScope by MainScope() {
private var diffJob: Job? = null
}

and cancel it this way:

override fun onDetachedFromRecyclerView(
recyclerView:RecyclerView
) {
super.onDetachedFromRecyclerView(recyclerView)
cancel()
}

Now you’re ready to write the function:

Putting all snippets together:

That’s it! You’re all set — all the adapter parts are written.

Complexity

Time

  • Building AnyTypeCollection has a complexity of θ(n) due to the zipWithNext function which iterates through itemsMetaData collection.
  • getItemViewType() has a complexity of θ(log n) due to the findCurrentItemViewTypePosition function that performs a binary search.

Space

  • The items collection — θ(n).
  • The itemsMetaData collectionθ(1) in case of items are all of the same type. Otherwise, θ(n) where n — times when item view type changes to the other one.
  • The positionsRanges collectionsame as itemsMetaData complexity

Conclusion

This way you have been able to create the RecyclerView Adapter for building complex lists. To see full usage guide or get the latest updates and optimizations for this adapter please check this section at the project’s GitHub repo

--

--