AndroidX Selection Library with DiffUtils ListAdapter

Recently I had to implement touch and hold to select behaviour for a RecyclerView (and merge adapter, covered later in the article). At first, I thought, easy - I'll probably stick a list of selected items in the ViewModel, pass that into the adapter, and update with an onLongClick listener in my ViewHolder. That might have worked, but then I found out about the AndroidX selection library and it seemed to fit my requirements very well.

Or so I thought. This blog post is for anyone that has tried to implement selection with this library and has been met with crashes and confusion. It took me a while to figure out the best and most reliable way to get this working with a ListAdapter (that is, the RecyclerView.Adapter with DiffUtils built in), and I settled on the method I will share below.

Background

At the time of writing, I had the following dependencies in my build.gradle file 
implementation "androidx.recyclerview:recyclerview:1.2.0-alpha03"
implementation 'androidx.recyclerview:recyclerview-selection:1.0.0'
Let me know in the comments if things have changed since then. 

My ViewModel provides the fragment with a new list as soon as data changes, and then the fragment passes this data onto my adapter with .submitList(). The ListAdapter documentation is very good at explaining this setup. Here's how I currently setup and bind my ViewHolder:
... the rest of my adapter code
class MyViewHolder(
    override val containerView: View
) :
    RecyclerView.ViewHolder(containerView), LayoutContainer {

    fun bind(
        item: MyData) = with(itemView) {
        // update views with data
        ...
    }

    companion object {
        fun from(parent: ViewGroup): MyViewHolder {
            val view = LayoutInflater.from(parent.context).inflate(
                R.layout.my_list_item,
                parent, false
            )
            return MyViewHolder(view)
        }
    }
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    return MyViewHolder.from(parent)
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.bind(
        getItem(position) as MyData)
}
Pretty standard, I believe. Now we can get started on implementing selection

Selection

To keep the article short I'll simply walk through the steps I went through to implement selection - I ran into a lot of problems along the way since most of the resources online for this problem do not work when the data in the list changes. It was easy to add support for selection on a static list, but as soon as the list changed... uh oh. Luckily I came across this singular post which helped me, however the solution needed tweaking (for example, I didn't use stableIds).

Key Provider

All of my data had an integer primary key that I could use as an ID, so out of the supported key data types (String, Parcelable and Long), I'll be using Long. First of all we need a Key Provider. Instead of using the ready-made StableIdKeyProvider, we will create our own. I've put mine inside the adapter class for organisational sake.
class KeyProvider(private val adapter: MyAdapter) : ItemKeyProvider<long>(SCOPE_CACHED) {
    override fun getKey(position: Int): Long? = adapter.currentList[position].id.toLong()
    override fun getPosition(key: Long): Int =
        adapter.currentList.indexOfFirst { it.id.toLong() == key }
}
Our KeyProvider will simply extend the ItemKeyProvider, and I've gone for SCOPE_CACHED since I don't need extra functionality such as shift-click, but I believe this should work with SCOPE_MAPPED also.  We're passing in the adapter so that we can reference the current list (and current order of items)

This class will be called by our SelectionTracker when it would like to find the position for a key (and key for a position). This is not used to find out which item has been tapped or held down on, since this is instead dispatched to our ItemDetailsLookup class that will take in a MotionEvent and return the key and position of the item under the user's finger. This means that instead of the ViewHolder passing it's events back up to the SelectionTracker, the SelectionTracker is taking MotionEvents on the RecyclerView, and trying to find out which ViewHolder is being targeted.

It's important to understand the difference here since it may look like we're writing the same code twice!

Item Details Lookup

This class contains a method that is called by the selection library to find out which item is being selected (as explained above). The implementation of this class should be pretty standard, however for the list adapter, there are a few things to note. 
class DetailsLookup(val recyclerView: RecyclerView) : ItemDetailsLookup<Long>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
        val view = recyclerView.findChildViewUnder(e.x, e.y)
        if (view != null) {
            return (recyclerView.getChildViewHolder(view) as MyViewHolder).getItemDetails()
        }
        return null
    }
}
First off, notice that we are calling a method .getItemDetails() on our ViewHolder instead of storing them as a field and updating these on bind. This is because if the position of the ViewHolder was to change without onBind being called, then the item details would then be out of date - a cause of the crashes I was seeing. The rest of the code here is to find which ViewHolder is being selected - we need the RecyclerView instance to do this.
fun getItemDetails(): ItemDetailsLookup.ItemDetails<Long> =
    object : ItemDetailsLookup.ItemDetails<Long>() {
        override fun getPosition(): Int = bindingAdapterPosition
        override fun getSelectionKey(): Long? =
            (bindingAdapter as MyAdapter).currentList[bindingAdapterPosition].id.toLong()
    }
The method above should be in your ViewHolder class, and when called it will get return the position and id of the ViewHolder at the time of access. 

Displaying Selected State

The last piece of the puzzle in our Adapter, is to make sure that the selection is displayed in the ViewHolder's view. This can be done however you like - a TextView appearing, MaterialCardView.setChecked() or View.setActivated(). I used the latter, and updated my bind method as so, inside my ViewHolder:
fun bind(
    item: MyData,
    selectionTracker: SelectionTracker<Long>) = with(itemView) {
    bindSelectedState(this, selectionTracker.isSelected(item.id.toLong()))
    // updating view...
}

private fun bindSelectedState(view: View, selected: Boolean) {
    view.isActivated = selected
}
I completed this by setting the background of my view to a state list with a faint grey when selected. 

Notice that we need to pass the SelectionTracker into our ViewHolder. We'll make this a field in our Adapter class, however it will have to be initialised after the Adapter is created, since in order to create the SelectionTracker, we need to have a RecyclerView with Adapter already initialised.  Add the following line to your Adapter class and onBindViewHolder methods.
...
lateinit var selectionTracker: SelectionTracker<Long>
...
override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    holder.bind(
        getItem(position) as MyData,
        selectionTracker)
}
Now we need to write some code in our Fragment to attach these new classes to our RecyclerView, as well as deal with selection changes.

Initialising the Selection Tracker

In my fragment I have the selection tracker defined as a lateinit variable as I have done in the adapter. This allows me to call relevant functions onSaveInstanceState.

Here's a code snippet of setting up the adapter and selection tracker in onViewCreated
val adapter = MyAdapter()
recyclerview.adapter = adapter
val layoutManager = LinearLayoutManager(requireContext())
recyclerview.layoutManager = layoutManager

// Setup list
selectionTracker = SelectionTracker.Builder(
    "my_data", // this key is for the saved instance state - should be unique for  election tracking within activity
    recyclerview,
    MyAdapter.KeyProvider(adapter), // Our KeyProvider and DetailsLookup from earlier
    MyAdapter.DetailsLookup(recyclerview),
    StorageStrategy.createLongStorage() // Needed to store keys as Long
).withSelectionPredicate(SelectionPredicates.createSelectAnything()) // SelectionPredicates allows us to select any item but can be customised
    .build()
selectionTracker.onRestoreInstanceState(savedInstanceState) // Restore selection if available
adapter.selectionTracker = selectionTracker // Pass selectionTracker into the adapter 

// 
selectionTracker.addObserver(object : SelectionTracker.SelectionObserver<Long>() {
    override fun onSelectionChanged() {
        selectionTracker.selection // List of selected ids.
    }
})
Use the callback in onSelectionChanged to update your UI, and call selectionTracker.selection to get the list of selected items at any point. The selections should persist across rotation and even if the data is updated. 

Finally we need to call the .saveInstanceState method. One thing to note is that it is still possible for saveInstanceState() to be called even if onViewCreated() has not been, in which case our selectionTracker will be uninitialised. Hence we need the following check.
override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    // Can be called before onCreateView
    if (::selectionTracker.isInitialized) {
        selectionTracker.onSaveInstanceState(outState)
    }
}
And that's it. You should now have functioning selection!

Merge Adapter

It's also possible to adapt this in case your adapter has been included in a MergeAdapter. Simply change the KeyProvider to account for the offset, and use absoluteAdapterPosition in your item details. Let me know if another post talking about this would be appreciated.

Recap

Your adapter should look something like this...
class MyAdapter() :
    ListAdapter<MyData, MyAdapter.MyViewHolder>(
        MyDataDiffCallback()) {

    lateinit var selectionTracker: SelectionTracker<Long>

    class DetailsLookup(val recyclerView: RecyclerView) : ItemDetailsLookup<Long>() {
        override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
            val view = recyclerView.findChildViewUnder(e.x, e.y)
            if (view != null) {
                return (recyclerView.getChildViewHolder(view) as MyViewHolder).getItemDetails()
            }
            return null
        }
    }

    class KeyProvider(private val adapter: MyAdapter) : ItemKeyProvider<Long>(SCOPE_CACHED) {
        override fun getKey(position: Int): Long? = adapter.currentList[position].id.toLong()
        override fun getPosition(key: Long): Int =
            adapter.currentList.indexOfFirst { it.id.toLong() == key }
    }

    class MyViewHolder(
        override val containerView: View
    ) :
        RecyclerView.ViewHolder(containerView), LayoutContainer {

        fun bind(
            item: MyData>,
            selectionTracker: SelectionTracker<Long>) = with(itemView) {
            bindSelectedState(this, selectionTracker.isSelected(item.id.toLong()))   
            // Bind views
        }

        fun getItemDetails(): ItemDetailsLookup.ItemDetails<Long> =
            object : ItemDetailsLookup.ItemDetails<Long>() {
                override fun getPosition(): Int = bindingAdapterPosition
                override fun getSelectionKey(): Long? =
                    (bindingAdapter as MyAdapter).currentList[bindingAdapterPosition].id.toLong()
            }


        private fun bindSelectedState(view: View, selected: Boolean) {
            view.isActivated = selected
        }

        companion object {
            fun from(parent: ViewGroup): MyViewHolder {
                val view = LayoutInflater.from(parent.context).inflate(
                    R.layout.list_item,
                    parent, false
                )
                return MyViewHolder(view)
            }
        }
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        return MyViewHolder.from(parent)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.bind(
            getItem(position) as MyData,
            selectionTracker)
    }

}
Hopefully this article has helped - if you have any more questions, don't hesitate to leave a comment or hit me up on Twitter. 

Comments