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
Post a Comment