Implementing a modal selection helper for RecyclerView

I recently spent some time implementing one of the last missing pieces that prevented me from migrating some ListViews to the new and more powerful RecyclerView: a helper class to manage the selection of multiple list items while showing a modal action mode to allow performing actions on these items.

Since API 11 (Android 3.0), ListView and GridView provide a special mode called CHOICE_MODE_MULTIPLE_MODAL that handles this automatically. When you enable this option, the user is able to long-press a list item to switch the ListView/GridView to a selection mode, then select more items with simple taps. While in this mode, the ListView also launches a contextual action mode showing the possible contextual actions on top of the App Bar. Closing the action mode or deselecting the last item switches the ListView back to normal mode again.

In contrast, RecyclerView provides no built-in selection mode at all, since its purpose is to be as simple and modular as possible. However, this functionality can still be fully implemented into a simple third-party component and I’m going to detail how I ended up with my own solution which works even better than the original ListView code.


Scope, features and architecture

I wanted to focus on modal “multichoice” mode only. I did not want to implement single item selection or selection without showing the action mode. So I created a class named MultiChoiceHelper providing the following features:

  • Track selections and deselections
  • Correct the selections automatically in response to dataset changes (items inserted, deleted or moved)
  • Provide a way to save and restore the selections when the container Activity or Fragment gets re-created
  • Be layout-agnostic (compatible with lists, grids and others)
  • Allow predictive animations (not interfere with them)
  • Use the AppCompat version of contextual action modes. This way, the code will be compatible with older versions of Android as well, back to API 7 !

The libraries, snippets and tutorials I found online did not provide all of these features or went for bad architectural decisions like extending the RecyclerView class, so I decided to create my own version and tweak every bit of it.

That component would obviously need to interact with the adapter, but not with the RecyclerView itself: forcing a specific item to be re-drawn can be achieved by calling notifyItemChanged() on the adapter. Creating a custom adapter base class was another possibility, but I did not want to fall into the trap of inheritance hell: I already use various custom adapter base classes like CursorAdapter and wanted to continue using these classes in combination with the selection mode without having to rewrite them. So instead I went for a helper class that should be declared and used inside an adapter (composition over inheritance).

That helper class would replicate the portion of AbsListView’s API dedicated to items selection, by providing these identical methods:

  • clearChoices()
  • getCheckedItemCount()
  • getCheckedItemIds()
  • getCheckedItemPositions()
  • isItemChecked(position)
  • setItemChecked(position, value)
  • setMultiChoiceModeListener(listener)

Tracking selection changes

The first thing I implemented was simply the selection tracking. I looked at the original source code of the latest version of AbsListView (thank you for making Android open source) and extracted the useful parts of it.

Positions

The positions of the selected list items are stored in a SparseBooleanArray which ensures minimal memory usage. Another benefit of that class is that it can easily be serialized in a Parcel when needed to save or restore state.

public boolean isItemChecked(int position) {
return checkStates.get(position);
}
public void setItemChecked(int position, boolean value) {
boolean oldValue = checkStates.get(position);
checkStates.put(position, value);
    if (oldValue != value) {
if (value) {
checkedItemCount++;
} else {
checkedItemCount--;
}
}
}

It’s interesting to note that when an item is deselected, we set the value to false instead of deleting the whole entry. This is actually an optimization to avoid doing expensive array copies each time an entry is deleted. Other similar classes like SparseArray internally set a special “deleted” value when an entry is deleted, performing a single pass garbage collection later when iterating over the entries or retrieving the actual number of entries is required. SparseBooleanArray does not have this internal optimization but by using false as “deleted” value and by storing the actual number of checked items in a separate variable, we can avoid both the array copies on deletion and the garbage collection phase.

Item IDs

While storing the positions is enough for a basic usage, also storing the item IDs offers interesting advantages.

So what is an item ID ? Also called stable ID, it’s a unique long value associated with each entry in the adapter. Thanks to these, the RecyclerView is able to track the adapter items more efficiently and figure out by itself which items moved to which position after a global data set change, allowing automatic animations and less redraws. It’s also useful for our MultiChoiceHelper: if checked items are moved or deleted, we want to be able to correct the checked positions automatically.

To be able to do that, we store the item IDs (keys) along with their associated current position (values) in a LongSparseArray<Integer>. This class stores boxed Integer objects as values which is a bit less memory-efficient than storing primitive int values, but since these are sequential integer positions starting from zero, there is a good probability that they are already inside Java’s Integer boxing cache.

Since stable IDs support is optional for an adapter, our helper class first determines if the LongSparseArray needs to be created during initialization:

if (adapter.hasStableIds()) {
checkedIdStates = new LongSparseArray<>(0);
}

Then when (de)selecting we check its presence:

final long id = adapter.getItemId(position);
if (checkedIdStates != null) {
if (value) {
checkedIdStates.put(id, position);
} else {
checkedIdStates.delete(id);
}
}

You’ll note that we actually call delete() here, contrary to the SparseBooleanArray, because LongSparseArray includes the internal delete optimization I mentioned before.

Another small advantage of storing the checked item IDs is that we can immediately return them to the caller, in sorted order, without having to query the adapter.

Tracking data set changes

In order to possibly update our checked indexes on data set changes, we need to register an internal AdapterDataObserver which will in turn call a cleanup method in case of insertion, deletion or moving of items. Hopefully, nothing needs to be done in case of a simple item change, which is the kind of notification our helper uses to request a redraw of the items after they are selected or deselected.

That cleanup method is the most complex method in the class. I copied the main portion of its code from AbsListView. It rebuilds the checked positions list by going through the list of all the checked item IDs and trying to find their new position within a range of items around the old position. If the item ID is not found in the range, the item is removed from the selection. The original code could cause a crash when the total number of items decreased because it was missing a range check before calling the adapter, so I fixed that. The original code also did not perform any cleanup when the adapter does not support stable IDs, so I also added a new routine to simply remove out-of-range positions in that case to avoid issues. Finally, I added an optimized path to simply clear everything when the adapter contains zero items. You can check the full code below.

It’s important that our helper class registers itself as first observer before the RecyclerView, so it can perform this cleanup before the RecyclerView is notified and starts rebinding some items using the already updated helper information. That’s why I recommend that you create and register the helper class from inside the constructor of your adapter.

Saving and restoring state

I implemented the code to save and restore state the same way Views do, even if our helper class is not a View: by creating and returning a Parcelable object in a method called onSaveInstanceState() and restoring its internal state from the same object in onRestoreInstanceState().

Restoring state is actually done in two steps: first updating the internal data structures then performing a cleanup (because the adapter data set may have changed between save and restore). That cleanup needs to be done after the adapter gets a chance to be populated with data, so it’s deferred until the first data set change or the next layout pass, whichever comes first.

You then have to call these two methods during the proper lifecycle callbacks in your Activity or Fragment. onSaveInstanceState() must be called from the callback with the same name while onRestoreInstanceState() may be called from any callback taking the saved state as argument.

For example, in Activities it would look like this:

@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable(KEY_ADAPTER, adapter.onSaveInstanceState());
}

@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
adapter.onRestoreInstanceState(savedInstanceState.getParcelable(KEY_ADAPTER));
}

And in Fragments:

@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putParcelable(KEY_ADAPTER, adapter.onSaveInstanceState());
}

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (savedInstanceState != null) {
adapter.onRestoreInstanceState(savedInstanceState.getParcelable(KEY_ADAPTER));
}
}

Your adapter would forward these two calls to the helper class.

Implementing the contextual action mode

The original AbsListView provides an interface called MultiChoiceModeListener which allows the caller to receive callbacks from the contextual action mode in order to implement its display and behavior, as well as get notified each time an item is added to or removed from the selection. This interface is an extension of the framework’s ActionMode.Callback interface.

I re-created the exact same interface for our helper, the only difference being that my version would extend the support library’s version of ActionMode.Callback so it can be used with any Android version.

The contextual action mode in action

The helper would then call AppCompatActivity.startSupportActionMode() when the first item gets selected. However, it can not just pass the MultiChoiceModeListener instance from the caller because the helper itself also needs to listen to the callbacks to be notified when the action mode is finished because of external factors: the user navigating back or the caller invoking ActionMode.finish() in response to user action. The solution is to use a wrapper implementation of MultiChoiceModeListener: a proxy that will simply forward all method calls to the caller implementation, but also properly react to the action mode destruction:

@Override
public void onDestroyActionMode(ActionMode mode) {
wrapped.onDestroyActionMode(mode);
choiceActionMode = null;
clearChoices();
}

Transitioning in and out of selection mode

Selection mode is active as long as at least one item is part of the current selection. When items are selected and deselected individually, the setItemChecked() method takes care of notifying the RecyclerView to update the display of that item.

adapter.notifyItemChanged(position);

However, when the selection state has just been restored or when the contextual action mode is destroyed and requires clearing the whole selection, the display of all selected items needs to be updated at the same time. First I thought I could just notify the RecyclerView to refresh all visible items without distinction:

adapter.notifyItemRangeChanged(0, adapter.getItemCount());
List items crossfading when closing the action mode

This works well enough and we even get crossfade animations for free on devices running API 11+. But we can do better. Since the item positions are sorted in our SparseBooleanArray, we can retrieve the min and max values easily and notify the RecyclerView to only refresh the range of items that were actually modified:

if (checkedItemCount > 0) {
final int
start = checkStates.keyAt(0);
final int end = checkStates.keyAt(checkStates.size() - 1);
adapter.notifyItemRangeChanged(start, end - start + 1);
}

This will in most cases prevent many unnecessary rebindings and redraws.

Interacting with the item views

The last piece of the puzzle is about making the item views interact with our helper class. It’s the adapter’s responsibility to:

  • Update the item view so that it reflects its current selection state. This must be done when binding the ViewHolder.
  • React to item presses and long presses and call the appropriate helper method. This can be setup once when creating the ViewHolder.

This boilerplate code could be written in the adapter itself, but since I didn’t want to copy it in each adapter and also didn’t want to create a custom Adapter base class, instead I chose to provide an extended ViewHolder base class with a default implementation that would cover the most common case. Here is what it does.

Showing the selection state

To reflect the current selection state, that custom ViewHolder provides the following method to be called when binding it:

public void bind(MultiChoiceHelper multiChoiceHelper, int position) {
this.multiChoiceHelper = multiChoiceHelper;
if (multiChoiceHelper != null) {
updateCheckedState(position);
}
}

First it sets the helper instance during binding to make sure it always uses the correct one for cases when the ViewHolder is shared between multiple RecyclerViews (this happens when you use a shared RecycledViewPool).

void updateCheckedState(int position) {
final boolean isChecked = multiChoiceHelper.isItemChecked(position);
if (itemView instanceof Checkable) {
((Checkable) itemView).setChecked(isChecked);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
itemView.setActivated(isChecked);
}
}

Then after retrieving the current state of the item, it applies the same logic as AbsListView: if the root view implements the Checkable interface, it sets its checked state. Else it sets the view’s activated state (only available since API 11). If your app is designed to be compatible with older versions, you need to go for the Checkable solution. It’s actually quite easy to create a Checkable version of any View: CheckableLinearLayout is a good example.

Once you do that, all the magic will happen thanks to selector drawables and ColorStateLists. You can combine background and foreground drawables. You can use android:duplicateParentState so that drawables of child views inherit the checked state of their parent (this is not needed for the activated state which automatically propagates to child views). An example of background drawable reflecting the checked state would be:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">

<item android:state_checked="true">
<shape>
<solid android:color="#663479c4" />
</shape>
</item>
<item android:drawable="@android:color/transparent" />

</selector>

Handling touch events

Responding to touch events is as simple as setting two listeners on the root view inside the ViewHolder’s constructor:

itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (multiChoiceHelper.getCheckedItemCount() > 0) {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
multiChoiceHelper.toggleItemChecked(position);
}
} else {
if (clickListener != null) {
clickListener.onClick(view);
}
}
}
});

The click listener toggles the selected state of the item during selection mode. During normal mode, the call is simply forwarded to an optional default click listener.

itemView.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View view) {
if (multiChoiceHelper.getCheckedItemCount() > 0) {
return false;
}
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
multiChoiceHelper.setItemChecked(position, true);
}
return true;
}
});

The long click listener selects the first item during normal mode (switching to selection mode). During selection mode, long presses are simply ignored.

This code seemed fine until I noticed a visual glitch during item (de)selection, mostly visible on Android 4.x devices:

Visual glitch during selection

The item is blinking twice during selection instead of smoothly switching to its selected state. It turns out this happens because I added a selector background on the item view to reflect the pressed state. That background starts fading in during press, but then the item selection code immediately triggers an item change, asking the RecyclerView to replace the current cell with a new one. So the pressed cell abruptly ceases to exist and leaves the pressed state in the middle of the press animation (first blink), then crossfades to the new cell reflecting the selected state.

The fix is to add an additional boolean argument to the methods setItemChecked() and toggleItemChecked() to specify if the adapter should notify an item change or not. If not, it’s the caller’s responsibility to refresh the item view. In this case the caller is the ViewHolder so it can easily refresh itself after a click:

multiChoiceHelper.toggleItemChecked(position, false);
updateCheckedState(position);

And here is the result after that adjustment:

No more glitch

It looks even better on Android 5+ where you can see the ripple effect animation continuing after the item turns blue.

Of course, this default behavior is not mandatory and you can easily customize it. You could for example toggle the selection state when the user presses a logo inside the cell or allow long presses during selection mode, like the Gmail Android app does.


Source code

You can find the complete source file on my Github account.

Feel free to use it but don’t forget to give credit to the author.


Thank you for reading my first technical blog post. I hope you found it interesting and useful. There are more to come.
In the meantime, you can follow me on
Twitter and Google+.