Implementing Multiple ViewHolders in Android using Kotlin
In the imperative programming paradigm in Android, a RecyclerView is a widget used to display scrollable items. Normally, developers use a single type of item to populate data in the RecyclerView. <!--more--> Have you ever asked yourself how you can employ different types of data items in the same RecyclerView while maintaining a seamless experience?
This is where multiple ViewHolders come in. They allow us to pass different data objects during the RecyclerView callbacks. This way we can create more interactive and scalable applications.
Prerequisites
To follow along with this tutorial, you need to:
- Install Android Studio IDE, preferably the latest version.
- Be familiar with the Android RecyclerView.
- Have a basic knowledge of Kotlin and ViewBinding.
Goals
By the end of this tutorial, you will be able to:
- Understand why we need more than one ViewHolder class.
- Implement two-typed ViewHolders in a single adapter.
- Manage RecyclerView callback methods and their interactions.
Case description
To demonstrate how we can use multiple ViewHolder classes, we will create a simple application that displays a list of landmarks. The landmark item can contain an image or not.
Getting started
Create an empty project in Android Studio and give it a name of your choice.
Creating the row items
"Row" items are the layout files that form a unit item in the RecyclerView. Usually, we would use a single row item for each RecyclerView. Nevertheless, we can use more than one row item for the same RecyclerView. This is the main goal of using multiple viewHolders where each corresponds to a single layout.
Layout for item with image
To begin with, create a layout file named landmark_with_image.xml and paste the following code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/landmarkImage"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_marginBottom="10dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_launcher_foreground"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/landmarkWithImageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Test Title"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="@id/landmarkImage"
app:layout_constraintHorizontal_bias="0.025"
app:layout_constraintStart_toStartOf="@id/landmarkImage"
app:layout_constraintTop_toBottomOf="@id/landmarkImage" />
<TextView
android:id="@+id/landmarkWithImageDesc"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:layout_marginEnd="8dp"
android:maxLines="3"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/landmarkImage"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="@id/landmarkWithImageTitle"
app:layout_constraintTop_toBottomOf="@id/landmarkWithImageTitle"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>
The code above generates a cardView with an image and two textViews, one for the title and one for the description.
preview:
Layout for an item without image
Create a layout file named landmark_without_image.xml and paste the following code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:cardCornerRadius="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/landmarkTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Landmark Title"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.025"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/landmarkDesc"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:layout_marginEnd="8dp"
android:maxLines="3"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/landmarkTitle"
app:layout_constraintTop_toBottomOf="@id/landmarkTitle"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>
Unlike the previous layout, the one above does not contain an image.
preview:
Note: You can create as many layouts as you wish, based on the use case. The more the layouts, the more the viewHolders required.
Setting up the RecyclerView
In the activity_main.xml file, add the following code:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/landmarkRecyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/landmark_without_image" />
</androidx.constraintlayout.widget.ConstraintLayout>
preview:
Categorizing the items
As mentioned earlier, a landmark item can take either of the layouts above depending on whether it has an image or not.
enum class HasImage {
TRUE, FALSE
}
The above is an enum class used to determine the category of the landmark. Ideally, this will tell the adapter what kind of item to bind in the RecyclerView at a given position.
Landmark data model
Each item should have a generic structure/model defined by a data class.
data class Landmark(
val title: String,
val desc: String,
var resource: Int?,
val hasImage: HasImage
)
Setting up RecyclerView adapter
An adapter class is responsible for populating the RecyclerView accordingly with the data provided.
class LandmarkAdapter(private var landmarks: ArrayList<Landmark>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
}
Note: The above viewHolder argument is not confined to a custom type. Instead, we have used the viewHolder from the inbuilt RecyclerView class. This allows us to apply many viewHolders in our adapter.
override fun getItemCount(): Int = landmarks.size
This method notifies the adapter of how many items to generate, usually the size of the data collection.
Moving on, we'll create two viewHolder classes for the two layout files we created earlier.
ViewHolder for landmarks with image
inner class LandmarkWithImageViewHolder(private val landmarkWithImage: LandmarkWithImageBinding) :
RecyclerView.ViewHolder(landmarkWithImage.root) {
fun bind(landmark: Landmark) {
landmarkWithImage.landmarkImage.setImageResource(landmark.resource!!)
landmarkWithImage.landmarkWithImageTitle.text = landmark.title
landmarkWithImage.landmarkWithImageDesc.text = landmark.desc
}
}
ViewHolder for landmark without image
inner class LandmarkWithoutImageViewHolder(private val landmarkWithoutImage: LandmarkWithoutImageBinding) :
RecyclerView.ViewHolder(landmarkWithoutImage.root) {
fun bind(landmark: Landmark) {
landmarkWithoutImage.landmarkTitle.text = landmark.title
landmarkWithoutImage.landmarkDesc.text = landmark.desc
}
}
The above viewHolders hold items of their respective type. They are called in other methods discussed below.
Determine the type of item
The following method is used to determine the type of item in a particular position.
override fun getItemViewType(position: Int): Int {
return if (landmarks[position].hasImage == HasImage.TRUE) HASIMAGE else NOIMAGE
}
These constants (return values) are defined in an object as shown below:
private object Const{
const val HASIMAGE = 0 // random unique value
const val NOIMAGE = 1
}
onCreateViewHolder method
This is where we return a viewHolder based on the type of the view item provided by the getItemViewType
method.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == HASIMAGE) {
val view =
LandmarkWithImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
LandmarkWithImageViewHolder(view)
} else {
val view =
LandmarkWithoutImageBinding.inflate(LayoutInflater.from(parent.context), parent, false)
LandmarkWithoutImageViewHolder(view)
}
}
onBindViewHolder method
When binding the data, we need to first check the type of an item relative to its position then pass it to the respective holder's bind
function. The holder is explicitly cast based on the viewType.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
if (getItemViewType(position) == HASIMAGE){
(holder as LandmarkWithImageViewHolder).bind(landmarks[position])
} else{
(holder as LandmarkWithoutImageViewHolder).bind(landmarks[position])
}
}
Generating data
The following is dummy data used for demonstration purposes. Ideally, you should retrieve structured data from a real database.
class LandmarkModel {
companion object {
fun getLandmarks(): ArrayList<Landmark> = arrayListOf(
Landmark("Mt. Kenya", "This is a mountain in Kenya 1", null, HasImage.FALSE),
Landmark("Mt. Kenya", "This is a mountain in Kenya 2", null, HasImage.FALSE),
Landmark("Mt. Kenya", "This is a mountain in Kenya 3", null, HasImage.FALSE),
Landmark(
"Mt. Kenya", "This is a mountain in Kenya 4", R.drawable.ic_launcher_background,
HasImage.TRUE
),
Landmark("Mt. Kenya", "This is a mountain in Kenya 5", null, HasImage.FALSE),
Landmark(
"Mt. Kenya", "This is a mountain in Kenya 6", R.drawable.ic_launcher_foreground,
HasImage.TRUE
),
Landmark("Mt. Kenya", "This is a mountain in Kenya 7", null, HasImage.FALSE),
Landmark("Mt. Kenya", "This is a mountain in Kenya 8", null, HasImage.FALSE)
)
}
}
The images referenced above, are generated by default when starting a project. You can use your images of choice as well.
populating the RecyclerView
The final step is to populate the RecyclerView with the data.
In the MainActivity.kt file, paste the following code:
class MainActivity : AppCompatActivity() {
private lateinit var mainBinding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// remember to set this value to null in the onDestroy method to avoid memory leaks.
mainBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(mainBinding.root)
val landmarks = LandmarkModel.getLandmarks()
mainBinding.landmarkRecyclerview.apply {
adapter = LandmarkAdapter(landmarks)
}
}
}
Testing the App
upon running the app, you should expect to see something similar to this.
Conclusion
In this tutorial, we have learned the fundamental concepts of displaying different types of items in a single RecyclerView with the use of multiple viewHolders. The knowledge gained in this tutorial can be applied to other more sophisticated use cases with the same goal.
You can find the full code implementation in this github repository.
Peer Review Contributions by: Eric Gacoki