Skip to main content

Modal selection for RecyclerView

A guide on how to implement modal selection using recyclerview-selection from Google’s Android Jetpack.

Gradle
implement "androidx.recyclerview:recycler-selection:$version"

Initial simple implementation

Implement collaborator classes

You can use Long, String, or Parcelable for the key types.

ItemKeyProvider
For many data sets, getting a position from a key requires traversing the set until the item is found. It’ll vary a lot so I leave it just as an extension function here with the implementation up to you.
private class KeyProvider(
    private val adapter: RecyclerView.Adapter
) : ItemKeyProvider<Long>(SCOPE_MAPPED) {
    override fun getKey(position: Int) = adapter.getItemId(position)
    override fun getPosition(key: Long) = adapter.getPosition(key) ?: NO_POSITION
}

fun RecyclerView.Adapter<*>.getPosition(key: Long): Int? = ...
ItemDetails
class ItemDetails : ItemDetailsLookup.ItemDetails<Long>() {
    var pos = 0
    var key: Long? = null
    override fun getPosition() = pos
    override fun getSelectionKey() = key
    // Mark the whole area of matching item view as interactable for selection.
    override fun inSelectionHotspot(e: MotionEvent) = true
}
ItemDetailsLookup
Use the MotionEvent to find the View interacted with and fetch relevant ItemDetails.
private class DetailsLookup(
    private val recyclerView: RecyclerView
) : ItemDetailsLookup<Long>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? =
        recyclerView.findChildViewUnder(e.x, e.y)
            ?.let { (recyclerView.getChildViewHolder(it) as MyViewHolder).details }
}
ViewHolder
Assign values to ItemDetails and mark the View activated state according to whether it is selected or not.
class MyViewHolder(
    itemView: View,
    val selectionTracker: SelectionTracker<Long>
) : RecyclerView.ViewHolder(itemView) {
    ...
    fun bind(itemData: ItemData, position: int) {
        ... // bindings or how ever you set your view values.
        itemView.isActivated = selectionTracker.isSelected(itemData.id)
        details.position = position
        details.key = itemData.id
    }
}

Create SelectionTracker

The SelectionTracker.Builder creates the selection controller and registers it with the RecyclerView. This will have to be worked around later on.

SelectionPredicate has two provided implementations, one for single selection, and another for multi selection. If you have an item set which has some items that can not be selected, you’ll have to implement SelectionPredicate yourself.
selectionTracker = SelectionTracker
    .Builder(
        "singleSelection",
        recyclerView,
        keyProvider,
        detailsLookup,
        StorageStrategy.createLongStorage()
    )
    .withSelectionPredicate(SelectionPredicates.createSelectAnything())
    .build()
    .apply { addObserver(selectionObserver) }
You can implement a SelectionObserver to receive callbacks on selection events.
If you followed the above you now have a RecyclerView with single or multi-selection. However, you have no way to toggle this behaviour on or off.

Implementing Modal Selection

To implement modal selection the above will be changed such that long clicks on items will enable selection. Selection will be disabled should zero items be selected, or should the user request it, say from a Toolbar menu item.

Touch events

To control the behaviour of SelectionTracker a shim will used. The shim will ensure the tracker receives all of the touch events, but the selectionModelEnabled flag controls whether the events are captured by SelectionTracker. Using this method allows the ncessary events to propagate through to the item views where long and single clicks will be handled.
class TouchPassthruListener(
    private val selectionListener: OnItemTouchListener
) : OnItemTouchListener by selectionListener {
    var selectionModeEnabled = false
    override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
        return selectionModeEnabled &&
            selectionListener.onInterceptTouchEvent(rv, e) &&
            e.action != MotionEvent.ACTION_CANCEL
    }
}

Encapsulate selection tracker

The next problem to deal with is isolating the callback SelectionTracker uses to intercept touch events. There are two approaches, use a throwaway RecyclerView subclass to capture the listener, or use the subclass as the actual RecyclerView. I did the second so I’ll put that here. To make it easy, this will all be done when the adapter is assigned.

Methods are added to enter and exit the selection mode. Adding a SelectionObserver is exposed so the Activity or Fragment can update toolbars and the like. The usecase for exiting the selection mode when 0 items are selected is handled by using another SelectionObserver.
class MyRecyclerView : RecyclerView {
    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
    constructor(
        context: Context,
        attrs: AttributeSet?,
        defStyle: Int
    ) : super(context, attrs, defStyle)

    lateinit var selectionTracker: SelectionTracker<Long>
    private var captureOnItemTouchListener = false
    private var capturedOnItemTouchListener: OnItemTouchListener? = null
    private lateinit var multiSelectionTouchListener: TouchPassthruListener

    fun setSelectionObserver(observer: SelectionObserver) {
        selectionTracker.addObserver(observer)
    }

    fun enterMultiSelectionMode(itemId: Long? = null) {
        multiSelectionTouchListener.selectionModeEnabled = true
    }

    fun exitMultiSelectionMode() {
        selectionTracker.clearSelection()
        multiSelectionTouchListener.selectionModeEnabled = false
    }

    override fun setAdapter(adapter: RecyclerView.Adapter<*>?) {
        super.setAdapter(adapter)

        createMultiSelection()

        (adapter as? HoldsRecyclerView)?.setRecycler(this)
            ?: throw IllegalArgumentException( "Adapter must implement HoldsRecyclerView.")
    }

    private fun createMultiSelection() {
        captureOnItemTouchListener = true
        selectionTracker = SelectionTracker
            .Builder(
                "singleSelection",
                this,
                KeyProvider(adapter),
                DetailsLookup(this),
                StorageStrategy.createLongStorage()
            )
            .withSelectionPredicate(SelectionPredicates.createSelectAnything())
            .build()
            .apply { addObserver(ExitObserver(this)) }
        multiSelectionTouchListener = TouchPassthruListener(capturedOnItemTouchListener!!)
        addOnItemTouchListener(multiSelectionTouchListener)
    }

    override fun addOnItemTouchListener(listener: OnItemTouchListener) {
        // If capture is set, then store the listener rather than passing it along.
        if (captureOnItemTouchListener) {
            capturedOnItemTouchListener = listener
            captureOnItemTouchListener = false
        } else {
            super.addOnItemTouchListener(listener)
        }
    }

    private inner class ExitObserver(
        private val tracker: SelectionTracker<Long>
    ) : SelectionTracker.SelectionObserver<Long>() {
        override fun onItemStateChanged(key: Long, selected: Boolean) {
            if (tracker.selection.size() == 0) exitMultiSelectionMode()
        }
    }
}

Final touches

The final step is to add the listeners that will trigger entering and exiting the selection mode. This can be done in the ViewHolder

You can also hook up the enter and exit actions to toolbar menu items or however you like.
class MyViewHolder(
    itemView: View,
    val recyclerView: MyRecyclerView
) : MyRecyclerView.ViewHolder(itemView) {
    ...
    init {
        view.setOnLongClickListener {
            recyclerView.enterMultiSelectionMode(details.selectionKey)
            true
        }
        view.setOnClickListener {
            val itemId = details.selectionKey?.toInt() ?: return@setOnClickListener
            it.findNavController()
                .navigate(MyFragmentDirections.ActionOpenItem().setItemId(itemId))
            recyclerView.exitMultiSelectionMode()
        }
    }

    fun bind(itemData: ItemData, position: int) {
        ... // bindings or how ever you set your view values.
        itemView.isActivated = recyclerView.selectionTracker.isSelected(itemData.id)
        details.position = position
        details.key = itemData.id
    }
}

Now you have a RecyclerView with modal multi-selection. There is also band selection just like Google Photos and many other apps. The next step is to hook up the Toolbar for an action mode while in the selection mode. Another time.

Comments