Generic recyclerview for all occasions

Generic recyclerview for all occasions

ยท

6 min read

RecyclerView with all its glory and advancements through which we can make complex lists is still too bloated and lengthy to write as a developer. I mean to say that writing an adapter class, then a viewholder class then defining the logic for click actions and binding views and all the other magic that goes into making a recyclerview is just too much to write repeatedly.

Ever felt tired after writing long and complex classes and then you see a design that wants you to implement a simple list but you realize while writing the adapter class that it is so painfully wrong to write such classes for such simple use cases?

I mean don't get me wrong here, I don't mean to say that Google did anything bad or RecyclerView is written wrong. I know for sure that the RecyclerView is written in the most sane way possible. The amount of flexibility and features that RecyclerView provides is amazing. But even with all that goodness attached to it, it seems like there is something that we can do to it make it DRY (Do not Repeat Yourself).

So we are gonna try and make this RecyclerView as Generic as possible.

The problem

Let's try to implement a simple list of States. I am going to use the ListAdapter to implement the RecyclerView since it has inbuilt support for DiffUtils which automatically handles the list changes and corresponding UI animations.

Below is the code that we will have to write to show a list of String items.

1. Data

data class StatesData(
    val id: Int,
    val state: String,
)

2. The ViewHolder

class StatesViewHolder(view: View): RecyclerView.ViewHolder(view) {

    fun bind(state: String) {
        itemView.findViewById<TextView>(android.R.id.text1).text = state
    }
}

3. The Adapter


val diffCallback = object : DiffUtil.ItemCallback<StatesData>() {
    override fun areItemsTheSame(oldItem: StatesData, newItem: StatesData): Boolean {
        return oldItem.id == newItem.id
    }

    override fun areContentsTheSame(oldItem: StatesData, newItem: StatesData): Boolean {
        return oldItem == newItem
    }
}

class StatesAdapter : ListAdapter<StatesData, StatesViewHolder>(diffCallback) {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatesViewHolder {
        return StatesViewHolder(
            LayoutInflater.from(parent.context)
                .inflate(android.R.layout.simple_list_item_1, parent, false)
        )
    }

    override fun onBindViewHolder(holder: StatesViewHolder, position: Int) {
        holder.bind(getItem(position))
    }

}

4. The Activity

class MainActivity : AppCompatActivity() {

    private val states = listOf(
        StatesData(1, "Andhra Pradesh"),
        StatesData(2,"Arunachal Pradesh"),
        StatesData(3,"Assam"),
        StatesData(4,"Bihar"),
        StatesData(5,"Chhattisgarh"),
        StatesData(6,"Goa"),
        StatesData(7,"Gujarat"),
        StatesData(8,"Haryana")
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val adapter = StatesAdapter()

        binding.recyclerViewStates.layoutManager = LinearLayoutManager(this)
        binding.recyclerViewStates.addItemDecoration(DividerItemDecoration(this, RecyclerView.VERTICAL))
        binding.recyclerViewStates.adapter = adapter

        adapter.submitList(states)
    }
}

Even though using the ListAdapter has significantly reduced the boilerplate in the adapter class we still had to write the adapter class to make sure that the view gets initialized, a holder instance is created, and appropriate binding happens when onBind function is called.

After writing this for a few times, I realized that the adapter class never really does anything special. It is just the step of initializing the ViewHolder and binding data to it. In a simple sense, every Adapter is gonna create an instance of the ViewHolder and bind the appropriate data object to it based on its position.

The Solution

So if we were able to make an adapter that creates any Viewholder instance and binds data to it appropriately then we can simply reuse this everywhere. But before we make this Adapter generic we will have to first make the DiffUtil callback generic.

The DiffUtil callback class is no different from the adapter class. It is also tasked with comparing the keys of two objects and comparing the contents of two objects. So let's start with making a generic DiffUtil. For content comparison of two objects, we can make use of the equals method of an object. For the key of an object, we can use an interface class. Let's call this the ListItem just to make it obvious.

interface ListItem {
    val key: Long

    fun areItemsTheSame(other: ListItem) = this.key == other.key

    fun areContentsTheSame(other: ListItem) = this == other
}

Now we can use this Interface to create the GenericDiffCallback class

class GenericDiffCallback<T : ListItem> : DiffUtil.ItemCallback<T>() {
    override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
        return oldItem.areItemsTheSame(newItem)
    }

    override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
        return oldItem.areContentsTheSame(newItem)
    }
}

Notice how the overridden functions areItemsTheSame and areContentsTheSame use the implementation of the interface classes functions to evaluate the result. This means you can control this behavior from the ListItem interface itself. To demonstrate its use let's implement this ListItem interface in our data class.

data class StatesData(
    val id: Int,
    val state: String,
) : ListItem {

    override val key: Long = id.toLong()
}

Since the data classes in Kotlin implement the hashcode and equals function themselves, we don't really need to provide any other implementation.

Note: Had this been a normal class we would have to implement the hashcode and equals function inside them otherwise the list update would be messed up. But since for mapping data in Kotlin we generally use data classes, this will happen rarely.

Let's also make a generic ViewHolder class that exposes the onBind function based on the data object provided.

abstract class GenericViewHolder<T>(view: View) : RecyclerView.ViewHolder(view) {

     abstract fun onBind(data: T)

     open fun onCleared() {
         //To be invoked when the view is cleared or recycled.
     }
}

Now let's finish the final piece of this puzzle.
For the adapter class, since it really isn't the showstopper, I wanted it to be as minimal as possible, but at the same time, I wanted it to expose the ViewGroup so that the Viewholder can take care of its own initialization. Luckily Kotlin makes creating DSL very easy, so I took the approach of creating the following:

/**
 * Creates a generic list adapter.
 * @param [init] : lambda function to initialize the view-holder object.
 */
inline fun <T : ListItem, reified VH: GeenricViewHolder<T>> genericAdapterOf(
    crossinline init: (ViewGroup) -> VH
): ListAdapter<T, VH> {
    val diff = GenericDiffCallback<T>()

    return object : ListAdapter<T, VH>(diff) {
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
            return init(parent)
        }

        override fun onBindViewHolder(holder: VH, position: Int) {
            holder.onBind(getItem(position))
        }

        override fun onViewRecycled(holder: VH) {
            holder.onCleared()
            super.onViewRecycled(holder)
        }

    }
}

Wrapping it up


Let's tweak the ViewHolder class to use the GenericViewHolder class that we just created.

class StatesViewHolder(
    view: View,
    private val onStateClick: (StatesData) -> Unit
): GenericViewHolder<StatesData>(view) {

    companion object {
        fun create(parent: ViewGroup, onStateClick: (StatesData) -> Unit) : StatesViewHolder {
            return StatesViewHolder(
                LayoutInflater.from(parent.context)
                    .inflate(android.R.layout.simple_list_item_1, parent, false),
                onStateClick
            )
        }
    }

    override fun onBind(data: StatesData) {
        itemView.findViewById<TextView>(android.R.id.text1).text = data.state

        itemView.setOnClickListener {
            onStateClick(data)
        }
    }

}

I have added a static initializer function to the ViewHolder and also added a click action just to demonstrate the uses of this Generic Adapter. After all this effort we can now write our adapter class and use it.

val adapter = genericAdapterOf {
    StatesViewHolder.create(it) { state ->
        print("selected State: $state")
    }
}

//If we did not have a click action then this would have been
val adapter = genericAdapterOf { StatesViewHolder.create(it) }

That's all folks, try it out in your projects.

ย