Data Binding with Live Data for RecyclerView in Kotlin

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.

App with three radio buttons

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?

Radio buttons don't change when clicked

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.

Radio buttons change

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.

Conversation
  • Carlos says:

    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

  • zone says:

    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)

    • Andrey says:

      val list = listOf(1..100).flatten()

      • Kory Dondzila Kory Dondzila says:

        Can be a bit more concise with.
        val list = (1..100).toList()

    • Kory Dondzila Kory Dondzila says:

      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)

    • Kory Dondzila Kory Dondzila says:

      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.

  • Summon says:

    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!

    • Summon says:

      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())

    • Kory Dondzila Kory Dondzila says:

      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.

      • Summon says:

        OK! Looking forward for the updated code!

        Thanks

        • Kory Dondzila Kory Dondzila says:

          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.

          • Summon says:

            Wow, thanks a lot for the update and for keeping your promise to revisit your original post.
            Keep up the good work!

  • Jorge Palma says:

    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

    • Kory Dondzila Kory Dondzila says:

      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

      
      @Query("SELECT name FROM program WHERE id = :programId")
      fun getProgramName(programId: Int): LiveData<String>
      

      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.

      
      viewModel.sites.observe(viewLifecycleOwner) { resource ->
          if (resource?.data != null) {
              adapter.submitList(resource.data)
          }
      }
      

      Without seeing your code it’s hard to know what exact issue is, I hope this helps.

  • Bugs Bunny says:

    Overcomplicated

  • GW says:

    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.

  • Comments are closed.