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 : ViewModel() {
    private val _selectedID = MutableLiveData<Int>()
    val selectedID: LiveData<Int> = _selectedID

    init {
        _selectedID.value = 1
    }

    fun toggle(optionID: Int) {
        _selectedID.value = optionID
    }
}

The reason for the empty data tags is so we can use the DataBindingUtils to inflate the layout. It isn’t necessary here, but I like being consistent.


<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>
    </data>

    <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:clickable="false"
            android:checked="@{safeUnbox(optionID) == safeUnbox(viewModel.selectedID)}"
            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() {
    private lateinit var binding: FragmentMainBinding
    private lateinit var adapter: MainListAdapter
    private val viewModel = MainViewModel()

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

        binding.recyclerView.adapter = adapter

        binding.recyclerView.apply {
            addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
        }

        val list = listOf(1, 2, 3)
        adapter.submitList(list)
    }
}

The MainListAdapter creates the binding for each item and binds the data. There’s nothing really fancy here.


class MainListAdapter(
    private val viewModel: MainViewModel
) : DataBoundListAdapter<Int>(
    diffCallback = object: DiffUtil.ItemCallback<Int>() {
        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>(
    diffCallback: DiffUtil.ItemCallback<T>
) : ListAdapter<T, DataBoundViewHolder>(
    AsyncDifferConfig.Builder<T>(diffCallback)
        .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)

    init {
        lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
    }

    fun markAttach() {
        lifecycleRegistry.currentState = Lifecycle.State.STARTED
    }

    fun markDetach() {
        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.


override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataBoundViewHolder {
    val binding = createBinding(parent, viewType)
    val viewHolder = DataBoundViewHolder(binding)
    binding.lifecycleOwner = viewHolder
    return viewHolder
}

override fun onViewAttachedToWindow(holder: DataBoundViewHolder) {
    super.onViewAttachedToWindow(holder)
    holder.markAttach()
}

override fun onViewDetachedFromWindow(holder: DataBoundViewHolder) {
    super.onViewDetachedFromWindow(holder)
    holder.markDetach()
}

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.