Three years ago I wrote an article about binding a list of items to a RecyclerView that became quite popular. It’s not really a surprise, since creating lists is one of the most common use cases in Android development.
A lot of things happened since then. Most importantly we got an official architecture guideline from Google with the Android Architecture Components. Also much more projects are using Data Binding nowadays.
I thought it’s time to revisit this topic and see how we can achieve something similar using the AAC ViewModel, but this time we will go a little further. We will create a RecyclerView Adapter that can be used to bind multiple types of data in the same list.
Sample App
To showcase how can we bind data with multiple view types, we will create the following sample app:
The list has three types of items: headers, car listings, and some promoted listings that stand out from the rest.
ViewModel
We start with implementing the ViewModel. This will be really simple in our case. We just get the data from our data provider asynchronously, do some transformations on the raw data to make it suitable for our UI, then set the value of our LiveData
object which will be bound to the XML layout.
If you are not familiar with the AAC ViewModel I suggest reading about it first in the official Google documentation.
Note: I used Hilt for dependency injection and Coroutines for background processing in this sample project, but those are absolutely not necessary to understand and implement this.
@HiltViewModel | |
class CarListViewModel @Inject constructor( | |
private val carDataProvider: CarDataProvider | |
) : ViewModel() { | |
val data: LiveData<List<ItemViewModel>> | |
get() = _data | |
private val _data = MutableLiveData<List<ItemViewModel>>(emptyList()) | |
init { | |
loadData() | |
} | |
private fun loadData() { | |
// This is a coroutine scope with the lifecycle of the ViewModel | |
viewModelScope.launch { | |
// getCarListData() is a suspend function | |
val carList = carDataProvider.getCarListData() | |
val carsByMake = carList.groupBy { it.make } | |
val viewData = createViewData(carsByMake) | |
_data.postValue(viewData) | |
} | |
} | |
private fun createViewData(carsByMake: Map<String, List<CarData>>): List<ItemViewModel> { | |
val viewData = mutableListOf<ItemViewModel>() | |
carsByMake.keys.forEach { | |
viewData.add(HeaderViewModel(it)) | |
val cars = carsByMake[it] | |
cars?.forEach { car: CarData -> | |
val item = if (car.isAd) { | |
CarAdViewModel(car.make, car.model, car.price) | |
} else { | |
CarListingViewModel(car.make, car.model, car.price) | |
} | |
viewData.add(item) | |
} | |
} | |
return viewData | |
} | |
} |
The most important thing from the above code is the data
property which will hold all the data what we need for our UI. This data will contain a list of ItemViewModel
instances.
ItemViewModel
The ItemViewModel
in the above code is an interface which represents the ViewModel part of the MVVM architecture on a list item level. These are not subclasses of the ViewModel
class from AAC, but can also be observable (we will get back to this later).
You can see in the createViewData
function that we add different objects (HeaderViewModel
, CarAdViewModel
, CarListingViewModel
) to the list, depending on what we want to show on the UI. These are all implementations of the ItemViewModel
interface, and represent the individual items in the list.
Let’s see how it looks like in code, it’s much easier to understand that way:
interface ItemViewModel { | |
@get:LayoutRes | |
val layoutId: Int | |
val viewType: Int | |
get() = 0 | |
} | |
class HeaderViewModel(val title: String) : ItemViewModel { | |
override val layoutId: Int = R.layout.item_header | |
override val viewType: Int = CarListViewModel.HEADER_ITEM | |
} | |
class CarAdViewModel(val make: String, val model: String, val price: String) : ItemViewModel { | |
override val layoutId: Int = R.layout.item_car_listing_ad | |
override val viewType: Int = CarListViewModel.AD_ITEM | |
} | |
class CarListingViewModel(val make: String, val model: String, val price: String) : ItemViewModel { | |
override val layoutId: Int = R.layout.item_car_listing | |
override val viewType: Int = CarListViewModel.LISTING_ITEM | |
} |
All the ItemViewModel
implementations will specify their layoutId
what will be inflated when we need to show that item. They all have different properties that can be referenced in the layout files of the items to bind the data. For example here is the layout file of the HeaderViewModel
to see how the properties are bound to the view:
<?xml version="1.0" encoding="utf-8"?> | |
<layout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:tools="http://schemas.android.com/tools"> | |
<data> | |
<variable | |
name="itemViewModel" | |
type="com.tamaskozmer.flexiblerecyclerview.itemviewmodels.HeaderViewModel" /> | |
</data> | |
<LinearLayout | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
android:orientation="vertical" | |
android:paddingBottom="8dp"> | |
<TextView | |
android:layout_width="match_parent" | |
android:layout_height="wrap_content" | |
android:textColor="@android:color/black" | |
android:textSize="14sp" | |
android:layout_marginBottom="4dp" | |
android:text="@{itemViewModel.title}" | |
android:textStyle="bold" | |
tools:text="Toyota" /> | |
<View | |
android:layout_width="match_parent" | |
android:layout_height="1dp" | |
android:background="@android:color/darker_gray" /> | |
</LinearLayout> | |
</layout> |
Finally we also need to define the viewType
property for each ItemViewModel
, because the RecyclerView need to know the view type of its elements. These can be simple constants.
How It All Connects
To connect the list of ItemViewModel
s in the CarListViewModel
we will need to create two things. A custom RecyclerView.Adapter
implementation and a Binding Adapter. We only need to create these once, then we can reuse it for every screen, where we need to show a list of items.
BindableRecyclerViewAdapter
Let’s start with the RecyclerView.Adapter
implementation, it’s the more complicated of the two, but we will go through all of the important callbacks step by step.
class BindableRecyclerViewAdapter : RecyclerView.Adapter<BindableViewHolder>() { | |
var itemViewModels: List<ItemViewModel> = emptyList() | |
private val viewTypeToLayoutId: MutableMap<Int, Int> = mutableMapOf() | |
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindableViewHolder { | |
val binding: ViewDataBinding = DataBindingUtil.inflate( | |
LayoutInflater.from(parent.context), | |
viewTypeToLayoutId[viewType] ?: 0, | |
parent, | |
false) | |
return BindableViewHolder(binding) | |
} | |
override fun getItemViewType(position: Int): Int { | |
val item = itemViewModels[position] | |
if (!viewTypeToLayoutId.containsKey(item.viewType)) { | |
viewTypeToLayoutId[item.viewType] = item.layoutId | |
} | |
return item.viewType | |
} | |
override fun getItemCount(): Int = itemViewModels.size | |
override fun onBindViewHolder(holder: BindableViewHolder, position: Int) { | |
holder.bind(itemViewModels[position]) | |
} | |
fun updateItems(items: List<ItemViewModel>?) { | |
itemViewModels = items ?: emptyList() | |
notifyDataSetChanged() | |
} | |
} | |
class BindableViewHolder(private val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { | |
fun bind(itemViewModel: ItemViewModel) { | |
binding.setVariable(BR.itemViewModel, itemViewModel) | |
} | |
} |
- The
itemViewModels
represents the content of the list. - The
viewTypeToLayoutId
is a map that associates the possible view types of this adapter with the layout id of that specific view type. This is important, because when we create theViewHolder
we only know the view type, but we also need to know what layout to inflate for thatViewHolder
.
And the callbacks what we need to implement are the following:
onCreateViewHolder
– We use theDataBindingUtil
here to inflate our layout, because we are using Data Binding in the items as well. We inflate the layout into aViewDataBinding
object and create theBindableViewHolder
with it. Later we can set variables to this binding, but this method is only responsible for creating theViewHolder
. We use theviewTypeToLayoutId
here to get the correct layout id.onBindViewHolder
– This is where we can set the variables for the binding, in this case only theitemViewModel
itself.getItemViewType
– This is a very important part of the implementation. Here we can use theposition
parameter to get the item and associate its layout id with its view type.
There is also the updateItems
method, what we will call from the Binding Adapter. This will just update the items and call notifyDataSetChanged
. If you want to implement a more sophisticated version with animations, you can use DiffUtil and notify each item change separately.
Binding Adapter
@BindingAdapter("itemViewModels") | |
fun bindItemViewModels(recyclerView: RecyclerView, itemViewModels: List<ItemViewModel>?) { | |
val adapter = getOrCreateAdapter(recyclerView) | |
adapter.updateItems(itemViewModels) | |
} | |
private fun getOrCreateAdapter(recyclerView: RecyclerView): BindableRecyclerViewAdapter { | |
return if (recyclerView.adapter != null && recyclerView.adapter is BindableRecyclerViewAdapter) { | |
recyclerView.adapter as BindableRecyclerViewAdapter | |
} else { | |
val bindableRecyclerAdapter = BindableRecyclerViewAdapter() | |
recyclerView.adapter = bindableRecyclerAdapter | |
bindableRecyclerAdapter | |
} | |
} |
If you are familiar what are Binding Adapters, this looks really simple. If the RecyclerView
doesn’t have an adapter yet, we create a new one and assign it to the RecyclerView
. After that we call the updateItems
method on the adapter with our data.
This is how it looks like in the XML of the MainActivity
.
<?xml version="1.0" encoding="utf-8"?> | |
<layout xmlns:android="http://schemas.android.com/apk/res/android" | |
xmlns:app="http://schemas.android.com/apk/res-auto"> | |
<data> | |
<variable | |
name="viewModel" | |
type="com.tamaskozmer.flexiblerecyclerview.CarListViewModel" /> | |
</data> | |
<androidx.recyclerview.widget.RecyclerView | |
android:layout_width="match_parent" | |
android:layout_height="match_parent" | |
android:paddingStart="16dp" | |
android:paddingEnd="16dp" | |
app:itemViewModels="@{viewModel.data}" | |
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/> | |
</layout> |
And finally the code of the MainActivity
where we inflate the binding and set the viewModel
property.
@AndroidEntryPoint | |
class MainActivity : AppCompatActivity() { | |
private val viewModel: CarListViewModel by viewModels() | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
val binding = ActivityMainBinding.inflate(layoutInflater) | |
binding.lifecycleOwner = this | |
binding.viewModel = viewModel | |
setContentView(binding.root) | |
} | |
} |
That’s it. We created a flexible custom RecyclerView Adapter that can be used on any screen with list content and we don’t need to create RecyclerView Adapters and ViewHolders anymore.
And the best thing is that this makes our code easily testable, since all the logic regarding the content of the list will be in the ViewModel
. If we have any complex logic in the items, they can be also tested separately.
Job Offers
Bonus Improvements
While this code works great, it’s simple and reusable, there is always room for improvement. In most cases we won’t need these, but if code becomes more complex we might benefit from it.
Making an ItemViewModel observable
We sometimes have items that can react to specific events and change their appearance. Let’s take the previous promoted car listing item with the red border for example.
When the user clicks the price of the car, we want to change the color of the border. If we wanted to achieve this with the current implementation, we would need to create a new itemViewModel
at that position and update the list. This is far from ideal.
If we make the CarAdItemViewModel
observable by subclassing it from the BaseObservable
class we can change the borderColor
property when the price is clicked, notify that the property is changed and the UI will update.
class CarAdViewModel( | |
val make: String, | |
val model: String, | |
val price: String, | |
@get:Bindable var borderColor: Int | |
) : BaseObservable(), ItemViewModel { | |
override val layoutId: Int = R.layout.item_car_listing_ad | |
override val viewType: Int = CarListViewModel.AD_ITEM | |
fun onCarPriceClick() { | |
borderColor = getRandomColor() | |
notifyPropertyChanged(BR.borderColor) | |
} | |
} |
Binding any property to the item layouts
In the previous example we are only binding the itemViewModel
property to the inflated layout in the BindableViewHolder
. In this case we will access the itemViewModel
in the XML.
If we want to make this more flexible by defining what properties we want to bind, we can modify our code like this:
interface ItemViewModel { | |
... | |
val bindableProperties: Map<Int, Any> | |
} | |
class CarAdViewModel(val make: String, val model: String, val price: String) : ItemViewModel { | |
... | |
override val bindableProperties: Map<Int, Any> = mapOf( | |
BR.make to make, | |
BR.model to model | |
) | |
} | |
class BindableViewHolder(private val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) { | |
fun bind(itemViewModel: ItemViewModel) { | |
itemViewModel.bindableProperties.entries.forEach { | |
binding.setVariable(it.key, it.value) | |
} | |
} | |
} |
Thanks for reading my article. I hope you found it useful, and it can help you make your users happier.
If you liked it share it with fellow Android developers. If you have any questions or suggestions feel free to leave a comment.
The complete code of this sample project can be found in the following Github repository: