16 Comments

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.