RecyclerView 2020: a modern way of dealing with lists in Android using DataBinding — Part 1
RecyclerView
is undoubtedly one of the most important and widely used UI widgets available in Android SDK nowadays, and it’s really hard to imagine a modern application with no lists at all. There are hundreds of articles about this component and there is a lot of great libraries simplifying its usage provided by the community. Nevertheless, I’d like to add my 5 cents here and discuss traditional approaches of working with lists, what problems they bring, and how they can potentially be solved by using Android Data Binding Library.
Let’s take a look at a canonical implementation of aRecyclerView.Adapter
, shamelessly borrowed from the official docs:
class MyAdapter : RecyclerView.Adapter<MyAdapter.MyViewHolder>() {
class MyViewHolder(
val textView: TextView
) : RecyclerView.ViewHolder(textView)
private var data: List<String> = emptyList()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): MyViewHolder {
val inflater = LayoutInflater.from(parent.context)
val textView = inflater.inflate(R.layout.my_text_view, parent, false) as TextView
return MyViewHolder(textView)
}
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
holder.textView.text = data[position]
}
override fun getItemCount() = myDataset.size
fun updateData(list: List<String>) {
data = list
notifyDataSetChanged()
}
}
Quite a lot of code for such a simple task as rendering a list of strings on screen, isn’t it? But what’s even more important, this adapter is tightly coupled to a single data type (String
) and XML layout. Essentially, we often have a 1 : 1 mapping between the number of lists in the app and the number of RecyclerView.Adapter
implementations: we have to create a new adapter (including RecyclerView.ViewHolder
) pretty much every time we’re dealing with a new list. What’s even worse, a significant part of these implementations consists of identical boilerplate code which is only there to match the contract of the RecyclerView.Adapter
abstract class. Pretty annoying I’d say. But let’s continue with this adapter anyway and try to link it to a hypothetical screen based on Fragment
or Activity
.
Luckily, the days when writing all the business logic right inside Activities and Fragments was a common practice are gone and a modern Android developer would extract it into a separate entity like Presenter
or ViewModel
:
class MyViewModel : ViewModel() {
val data = MutableLiveData<MyObject>()
init {
loadData()
}
private fun loadData() {
// ...fetch data from API / DB and put it to `data`
data.value = ...
}
}
View models & presenters do not know anything about RecyclerView
s, hence they can’t update the UI directly. Instead, we need to write some extra code in the UI layer to complete the chain:
class MyFragment : Fragment() {
val viewModel: MyViewModel by viewModels()
private val adapter = MyAdapter()
...
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
viewModel.data.observe(viewLifecycleOwner, Observer {
adapter.updateData(it)
})
}
}
Quite a typical code and I bet most of us have already seen or even written something very similar many times before. What problems does it have? First of all, we have to create an adapter manually and store a reference to it so it can be updated if the data set changes. Second, we have to set up observing the data exposed by View Model and react to these changes by updating the underlying adapter’s data set. While both these issues do not bring any architectural or ideological issues, they still lead to writing extra boilerplate code in our UI layer, we have to manually link the two worlds — plain & natural data flowing in the business logic layer (View Models, Repositories, etc.) and UI handled by Views, Fragments or Activities.
Well, we’ve found the following areas for improvement in the “classic” way of working with Lists & Adapters in Android:
- We have to create new & new adapters for almost every single individual
RecyclerView
in the project. These adapters can’t always be reused and contain a lot of boilerplate code. - It would be a bad idea to store
RecyclerView
's adapters right inside view models for lots of reasons (separation of concerns, view models reusability) so we have to expose domain-level data from View Models / Presenters and manually convert it to its UI representation by using adapters. This leads to writing extra code in Fragments and Activities + UI layer becomes the one that performs actual mapping, we expose domain (POJOs, Data Classes, etc.) format to the UI layer so it can do the conversion.
As some of you probably know, there is a solution to both of these problems and it’s called Android Data Binding framework. Let’s get started…!
To begin with, let’s create a new data
class which will be the only format used by our future universal Adapter:
- data will hold… your data. It can also be treated as a “view model of individual list item”. It can either be something as simple as a plain POJO or something more complex with actual business logic, our adapter will not care about that and it is up to you to decide depending on the use case.
- layoutId — this is the XML file containing the layout for rows of the list. This is where our adapter will try to find variableId to initialize it with the value of data. Essentially, this is a reference to a layout that will be used to render the data.
- variableId contains the name of the variable which will be used in your XML layout inside <data></data> tag. We’ll discuss this in more detail below. Also, check out the Data Binding : Dynamic Variables docs for more details.
Now it’s time to implement the universal DataBinding adapter itself:
That’s basically it, the MVP version of our universal adapter is ready. Let’s quickly discuss what’s going on here.
getItemViewType
: as you probably know, this callback is typically used to support multiple view types within singleRecyclerView
& adapter. We simply returnlayoutId
from ourRecyclerItem
and this trivial action allows our adapter to automatically handle different view types. We just use the generated XML layout’s ID as a “view type” identifier.onCreateViewHolder
: most of the data-binding magic happens here. We use DataBindingUtil to dynamically create a new ViewDataBinding based on XML layout ID. All data-binding enabled XMLs have a generated class derived from this base class. We don’t know the name of this class here but we know that it will be generated for the givenlayoutId
and it will inheritViewDataBinding
.onBindViewHolder
: here we need to link ourViewDataBinding
withviewModel
. Every data binding class has theserVariable()
method which accepts variable ID and data ofObject
type. As you might have guessed, these are ourvariableId
andviewModel
from theRecyclerItem
.
As we mentioned earlier, we’d like to avoid writing any glue code in our Fragments and Activities so the last step is to create a BindingAdapter
which can help us to do everything with just 1 line of code in Fragment or Activity XML:
Now it’s time to see how all of that works together in practice. Let’s say we need to implement a screen displaying a list of Users. Here’s how your POJO class may look like:
data class User(
val id: Int,
val firstName,
val lastname
)
Now let’s create a layout that will be used for individual rows:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="user"
type="com.package.User" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.lastName}" />
</LinearLayout>
</layout>
Nothing new here for those who are familiar with the Data Binding framework. Now back to our view model: we still need to fetch Users and then “put” them into a RecyclerView somehow.
class MyViewModel : ViewModel() {
val data = MutableLiveData<List<RecyclerItem>()
init {
loadData()
}
private fun loadData() {
SomeService.getUsers { users ->
data.value = users.map { it.toRecyclerItem() }
}
}
}
As you see, now we have List<RecyclerItem>
instead of something like List<User>
and we use user.toRecyclerItem()
method to convert our data classes into something which can be recognized by RecyclerView
— RecyclerItem
. We don’t want to spoil our POJOs with logic that belongs to another layer of abstraction (UI) so let’s instead create a private extension in the file holding the ViewModel
:
private fun User.toRecyclerItem() = RecyclerItem(
data = this,
variableId = BR.user,
layoutId = R.layout.item_user
)
That’s the only “glue” code we need to write in order to bridge the gap between ViewModel’s natural data format (list of Users) and RecyclerView
. The last step we need to do is to render the List<RecyclerItem>
in our UI. Thanks to DataBinding and the BindingAdapter
we created earlier, all of that can be done in XML:
<?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"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.package.MyViewModel" />
</data>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:items="@{viewModel.data}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>
</layout>
The final step would be to assign your actual ViewModel
to the viewModel
variable from above:
class MyFragment : Fragment() {
val viewModel: MyViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return FragmentMyScreenBinding.inflate(inflater, container, false)
.also {
it.viewModel = viewModel
it.lifecycleOwner = viewLifecycleOwner
}
.root
}
}
That’s it and that’s the only code you need to write in your Activities or Fragments when you use DataBinding and the “adapterless” approach we’re discussing in this article. Pretty nice, huh? :-)
Let’s revisit what we’ve done so far to check whether it is any better than the original version with “1 adapter for 1 list”.
- We no longer need to create new adapters. Ever. With minor additions, this
RecyclerViewAdapter
can be the only adapter in your project. - Our Fragments and Activities no longer need to worry about adapters and data observing. The only thing they need to do is to assign a
viewModel
to the corresponding XML layout and this looks & feels pretty natural. Everything else is handled in XML with just a couple of lines of code. - We no longer need to expose domain data format from View Models to Activities and Fragments. Instead, you just need to provide a generic list of
RecyclerItem
s which hides the actual data format from the UI andRecyclerItem
can be handled by the UI directly with no extra transformations (thanks to theDataBindingAdapter
andRecyclerViewAdapter
it uses under the hood). - You don’t need to spoil your POJOs or recycler item View Models by extending some base class, you don’t even need to implement any extra interface. The POJOs or view models you use for
RecyclerView
look like regular classes. You just need to write a mapper somewhere where it makes the most sense for you (static or extension function returning aRecyclerItem
). - There is no need to extend the adapter every time when a new item type is added. You can easily support as many view types in a single list as you want, you just need to define mappings from your data to
RecyclerItem
:
val data = MutableLiveData<List<RecyclerItem>()
fun loadData() {
val users: List<User> = ...fetch users...
val admins: List<Admin> = ...fetch admins...
data.value =
users.map { it.toRecyclerItem()) }
+ admins.map { it.toRecyclerItem() }
}
fun User.toRecyclerItem() = RecyclerItem(
data = this,
variableId = BR.user,
layoutId = R.layout.item_user
)
fun Admin.toRecyclerItem() = RecyclerItem(
data = this,
variableId = BR.admin,
layoutId = R.layout.item_admin
)
Much better than the new MergeAdapter
, isn’t it? :-)
What’s next? As I mentioned earlier, the current implementation is a bit simplified: it will work just fine for relatively static cases with rare updates of the content. But for more dynamic scenarios it would be great to also integrate DifUtil
and move its calculations to a background thread. I’ll try to uncover this topic (along with some other improvements) in the next article.
Thanks for reading and please let me know what you think about this approach ;-)
UPD: Part 2 of this series can be found here