Article summary
When working on an app in Kotlin or Android, it’s very common to use RecyclerViews to display lists of information. Typically, this data will be built up in ViewModels and passed to a list adapter. This works fine for most things and allows interaction with the items. Usually, they don’t need to change directly in the current fragment.
In some cases, however, you want the items to change directly by some live data in the ViewModel. One example might be when you have a list of options and want to display the current selection. This will require data binding with live data for the RecyclerView.
A Simple Example
To keep things simple, I’ll provide sample code for a RecyclerView with an options selection.
First, we’ll look at the MainViewModel that contains live data for the current selected option ID and a function that will change the selected option.
class MainViewModel @Inject constructor(): ViewModel() {
private val _selectedID = MutableLiveData<Int>()
val selectedID: LiveData<Int> = _selectedID
init {
_selectedID.value = 1
}
fun toggle(optionID: Int) {
_selectedID.value = optionID
}
}
The main fragment is simple enough, just a RecyclerView in a ConstraintLayout.
<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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/list_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
This is the item layout that will be used in the RecyclerView. Note that we will data-bind the optionID and ViewModel. This allows us to set an onClick method to toggle function in the ViewModel, passing the optionID. And we can observe the selectedID from the ViewModel to show if the RadioButton should be checked.
<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="optionID"
type="Integer" />
<variable
name="viewModel"
type="com.example.myapplication.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/list_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.toggle(optionID)}"
android:minHeight="44dp">
<androidx.appcompat.widget.AppCompatRadioButton
android:id="@+id/item_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:checked="@{safeUnbox(optionID) == safeUnbox(viewModel.selectedID)}"
android:clickable="false"
android:text="@{Integer.toString(safeUnbox(optionID))}"
android:textSize="20sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Next, we’ll look at the MainFragment. Note that the layout is inflated, and the list adapter has been created and set to the RecyclerView. We submit a list of ints to the adapter.
class MainFragment : Fragment(), Injectable {
@Inject
lateinit var appExecutors: AppExecutors
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
private val viewModel: MainViewModel by viewModels {
viewModelFactory
}
private lateinit var adapter: MainListAdapter
var binding by autoCleared<FragmentMainBinding>()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = DataBindingUtil.inflate(
inflater,
R.layout.fragment_main,
container,
false
)
binding.lifecycleOwner = viewLifecycleOwner
return binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
adapter = MainListAdapter(
appExecutors = appExecutors,
viewModel = viewModel
)
binding.recyclerView.adapter = adapter
binding.recyclerView.apply {
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
adapter.submitList((1..100).toList())
}
override fun onDestroyView() {
super.onDestroyView()
adapter.setLifecycleDestroyed()
}
}
The MainListAdapter creates the binding for each item and binds the data. There’s nothing really fancy here.
class MainListAdapter(
appExecutors: AppExecutors,
private val viewModel: MainViewModel
) : DataBoundListAdapter<Int>(
appExecutors = appExecutors,
diffCallback = object: DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: Int, newItem: Int): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: Int, newItem: Int): Boolean {
return oldItem == newItem
}
}
) {
override fun createBinding(parent: ViewGroup, viewType: Int): ViewDataBinding {
return DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.list_item, parent,
false
)
}
override fun bind(binding: ViewDataBinding, item: Int) {
when (binding) {
is ListItemBinding -> {
binding.optionID = item
binding.viewModel = viewModel
}
}
}
}
The DataBoundViewHolder simply extends the ViewHolder to be used for layouts that have data binding.
class DataBoundViewHolder constructor(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
Lastly, the DataBoundListAdapter will allow us to create any list adapters in our app that will work with data binding.
abstract class DataBoundListAdapter<T>(
appExecutors: AppExecutors,
diffCallback: DiffUtil.ItemCallback<T>
) : ListAdapter<T, DataBoundViewHolder>(
AsyncDifferConfig.Builder<T>(diffCallback)
.setBackgroundThreadExecutor(appExecutors.diskIO())
.build()
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataBoundViewHolder {
val binding = createBinding(parent, viewType)
val viewHolder = DataBoundViewHolder(binding)
return viewHolder
}
protected abstract fun createBinding(parent: ViewGroup, viewType: Int): ViewDataBinding
override fun onBindViewHolder(holder: DataBoundViewHolder, position: Int) {
if (position < itemCount) {
bind(holder.binding, getItem(position))
holder.binding.executePendingBindings()
}
}
protected abstract fun bind(binding: ViewDataBinding, item: T)
}
With our code out of the way, what do we get when running the app?
Not much, apparently. We can see that our list is visible and the text is set on each option, but clicking them does nothing. Why? We have data binding, we pass the ViewModel, and we observe data. But it doesn’t work.
The issue is that the RecyclerView ViewHolders are not able to observe changes to live data. This is because they are not LifecycleOwners, unlike what we might have for fragments.
Fixing the Live Data for RecyclerViews
The solution to the problem is quite simple. We need to make the ViewHolders LifecycleOwners.
Because the ViewHolders can be added and removed as the RecyclerView list changes, we need to update the state of the lifecycle.
class DataBoundViewHolder constructor(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root),
LifecycleOwner {
private val lifecycleRegistry = LifecycleRegistry(this)
private var wasPaused: Boolean = false
init {
lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
}
fun markCreated() {
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
fun markAttach() {
if (wasPaused) {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
wasPaused = false
} else {
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
}
fun markDetach() {
wasPaused = true
lifecycleRegistry.currentState = Lifecycle.State.CREATED
}
fun markDestroyed() {
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
override fun getLifecycle(): Lifecycle {
return lifecycleRegistry
}
}
In the DataBoundListAdapter, we need to set the LifecycleOwner of the binding and override the attach and detach functions to trigger the lifecycle changes. Since they only get destroyed when the fragment is destroyed a list of them needs to be maintained so we can destroy all the viewHolder lifecycles when the fragment is destroyed.
private val viewHolders: MutableList<DataBoundViewHolder> = mutableListOf()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataBoundViewHolder {
val binding = createBinding(parent, viewType)
val viewHolder = DataBoundViewHolder(binding)
binding.lifecycleOwner = viewHolder
viewHolder.markCreated()
viewHolders.add(viewHolder)
return viewHolder
}
protected abstract fun createBinding(parent: ViewGroup, viewType: Int): ViewDataBinding
override fun onBindViewHolder(holder: DataBoundViewHolder, position: Int) {
if (position < itemCount) {
bind(holder.binding, getItem(position))
holder.binding.executePendingBindings()
}
}
protected abstract fun bind(binding: ViewDataBinding, item: T)
override fun onViewAttachedToWindow(holder: DataBoundViewHolder) {
super.onViewAttachedToWindow(holder)
holder.markAttach()
}
override fun onViewDetachedFromWindow(holder: DataBoundViewHolder) {
super.onViewDetachedFromWindow(holder)
holder.markDetach()
}
fun setLifecycleDestroyed() {
viewHolders.forEach {
it.markDestroyed()
}
}
After these small changes, we can rerun our app.
We can see now that the radio buttons change when we click them. Those simple changes allow us to give any RecyclerView ViewHolder data binding and observable live data.
This is a more generic approach than what is provided in this post on Lua Software Code.
This post has been updated from the original, there’s some things that won’t be available or work out of the box, the appExecutors, autoCleared, Injectable, @Inject. These are provided in the dependencyInjection folder and is mostly code from Google example projects.
The code for this project can be found here.
Hi Kory
It’s a great article, any possibility to access to the full code, there are a couple of things I can find out how to do.
Thanks
I will need to rebuild this (unfortunately this one I didn’t repo) You can take a look at a few of my other posts to see if those help in the meantime and you can look through other Kotlin spin posts.
Updated code is now provided at the end of the post.
https://github.com/korydondzila/kotlin-data-binding
Hi Kory,
when list has more than the screen could have, after scroll in the item the click will not work.
val list = listOf(1, 2, 3, 4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20)
val list = listOf(1..100).flatten()
Can be a bit more concise with.
val list = (1..100).toList()
I will need to rebuild this project to see what the specific issue is. (This was originally a quick post where I didn’t keep a local copy of the code around which is bad on me, but luckily it’s all here for the most part)
You are correct in this happening, I’m not really sure why it is. The live data fires correctly, though the change was not observed on some of the items, though they are visible and bound.
I have made changes to fix this issue and provided my code for this. Effectively my changes are doing the exact same thing just the selectedID is now using an observer in the adapter rather than using the data binding in the layout… but oh well. Might have been a dependency issue otherwise.
Hi Kory!
When you select an option after random scrolls up and down the list, there is a ghost unselect effect on some option items that were not selected before. It is difficult to describe it. The best option is to run the code you have on Github. In any case I (think I) found the solution: change the line 42 in MainListAdapter from:
binding.itemLabel.isChecked = it == item
to
binding.itemLabel.isChecked = it == binding.optionID
I think the original code captures the new item value when the selectedID changes and fires the observer and not the one set to this option item on line 40. So, the solution is to use binding.OptionID instead of item.
I am also concerned about memory leaks when navigating away of this fragment or rotating the screen. Have you tested the code for these scenarios?
Thanks!
I forgot to mention that you have to add lots of items to the list in order to have scrolling in any screen size
e.g. adapter.submitList((1..100).toList())
Yeah I think there’s a discrepancy between the code in github and here after I had changed things. Forgot to update part of the post. There’s also a change that needs to happen to better handle life cycle in the RecyclerView. Which actually may have been a better fix for the original issue. I’ll have to revisit it, maybe this weekend.
OK! Looking forward for the updated code!
Thanks
Code and post are updated, things not covered in the post are the files in dependencyInjection as those are from Google example projects updated for this project. Main changes for this are the DataBoundViewHolder and DataBoundListAdapter and went back to passing the ViewModel to the list items.
Wow, thanks a lot for the update and for keeping your promise to revisit your original post.
Keep up the good work!
Hello, good morning, the example shown above works excellent for me, very good work, but I have a question, how can I add a filter to my LiveData List? I have tried to search the database with the filter and update the list With LiveData, but it does not make any change, the observer does not detect any change in the reduction of elements with the filter, will you have an example in which you can help me?
Thank you
Hey Jorge Palma,
If you are querying the database, make sure it’s returning a LiveData of the type and that you are observing it. In fact I would make sure all calls into the database are handled in your ViewModel
If you are filtering data after it comes back from the database, you can use a Transformations.map on it in your ViewModel
In either case you need to make sure you observe the data that’s changing and submit that to the adapter. Something like this.
Without seeing your code it’s hard to know what exact issue is, I hope this helps.
Overcomplicated
The whole dependency injection / lifecycle / executor stuff is way too complicated for me. Maybe I’m not experienced enough. I’ve only been developing Android apps for 5 years and just switched to Kotlin 3 months ago. :)
I don’t see any advantage in data binding here. You do the exact same thing without it – you bind the data in “onBindViewHolder()”. If this is all we can do then data binding makes no sense at all on RecyclerViews / list adapters.
Jarosław Michalik does it the right way:
https://www.untitledkingdom.com/blog/refactoring-recyclerview-adapter-to-data-binding-5631f239095f-0