From Punched Cards to Prompts
AndroidIntroduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Don’t miss Part 1, where Bill addresses the fundamentals of RecyclerView for ListView experts.
A famous man once said,
In this life, things are much harder than in the afterworld. In this life, you’re on your own.
Is this a true statement? Perhaps that matter is up for debate. When it comes to selecting items in a RecyclerView, though, you are, in fact, on your own: RecyclerView does not give you any tools for doing this. So how do you do it?
I figured this would be a straightforward exercise in rolling my own solution, so I dove right in. Here’s what I found out.
(If you like, you can see how I worked through all this in my GitHub repo. And if you just want to know how to do it the easy way, skip to the section named “TL;DR” at the end.)
I set out to implement multiselect
like we do in the CriminalIntent app in our Android book: with a contextual action mode. Here’s what that looks like in the code (for brevity, I’m showing only the interesting bits—you can find the whole thing in our solutions):
listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL);
listView.setMultiChoiceModeListener(new MultiChoiceModeListener() {
public boolean onCreateActionMode(ActionMode mode, Menu menu) { ... }
public void onItemCheckedStateChanged(ActionMode mode, int position,
long id, boolean checked) { ... }
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_delete_crime:
CrimeAdapter adapter = (CrimeAdapter)getListAdapter();
CrimeLab crimeLab = CrimeLab.get(getActivity());
for (int i = adapter.getCount() - 1; i >= 0; i--) {
if (getListView().isItemChecked(i)) {
crimeLab.deleteCrime(adapter.getItem(i));
}
}
mode.finish();
adapter.notifyDataSetChanged();
return true;
default:
return false;
}
public boolean onPrepareActionMode(ActionMode mode, Menu menu) { ... }
public void onDestroyActionMode(ActionMode mode) { ... }
});
ListView has this idea of choice modes. If the ListView is in a particular choice mode, it will handle all the details of displaying a checkable interface, keeping track of check marks and toggling all that stuff back and forth when individual items are tapped. You turn choice modes on by calling ListView.setChoiceMode()
, as you see above. To see whether an item is checked, you call ListView.isItemChecked(int)
(like you can see above in onActionItemClicked()
).
When you use CHOICE_MODE_MULTIPLE_MODAL
, long pressing any item in the list will automagically turn multichoice mode on. At the same time, it will activate an Action mode representing the multiselect interaction. The MultiChoiceModeListener
above is a listener for that contextual action mode—it’s like a set of option mode callbacks that are only called for this action mode.
In my earlier post on RecyclerView fundamentals, we saw that RecyclerView leaves you on your own for implementing all of this. You have three moving parts that need to be implemented:
In a perfect world, this will be something that you would actually want to do in practice, too. As I was writing this, though, I found my solutions falling short. I could imagine someone reading this post and just shaking their head: “Seriously? I need to roll this myself every time?”
So in this post, I’ll explain enough that you can roll your own easily if you need to, but also provide a library called MultiSelector that’s more of a drop-in solution.
This is the most straightforward, so let’s solve it first. In ListView, this works like so:
// Check item 0
mListView.setItemChecked(0, true);
// Returns true
mListView.isItemChecked(0);
// Says what the choice mode currently is
mListView.getChoiceMode();
Rolling our own looks like this:
private SparseBooleanArray mSelectedPositions = new SparseBooleanArray();
private mIsSelectable = false;
private void setItemChecked(int position, boolean isChecked) {
mSelectedPositions.put(position, isChecked);
}
private boolean isItemChecked(int position) {
return mSelectedPositions.get(position);
}
private void setSelectable(boolean selectable) {
mIsSelectable = selectable;
}
private boolean isSelectable() {
return mIsSelectable;
}
This will not update the user interface like ListView.setItemChecked()
, but it will do for now.
Of course, you can keep track of selections however you like. A Set of model objects can be a good choice, too.
I put this idea in an object called MultiSelector
:
MultiSelector selector = new MultiSelector();
selector.setSelected(0, true);
selector.isSelected(0);
selector.setSelectable(true);
selector.isSelectable();
In ListView from Honeycomb onwards, item selection has been visualized like this: whenever an item is selected, the view is set to the “activated” state by calling setActivated(true)
. When the view is no longer selected, it is set back to false. With that done, it is straightforward to tweak the selection mode by using XML StateListDrawables
to highlight the selection state.
You can do the same thing by hand in your ViewHolder
’s bindCrime
:
private class CrimeHolder extends ViewHolder {
...
public void bindCrime(Crime crime) {
mCrime = crime;
mSolvedCheckBox.setChecked(crime.isSolved());
boolean isSelected = mMultiSelector.isSelected(getPosition());
itemView.setActivated(isSelected);
}
}
Of course, if you want to display selection in another way, you can. The sky’s the limit. Drawables and state list animators make the activated state a good default choice, though.
If that were all there was, I wouldn’t have spent so much time on this. But I did, because I got stubborn on some visual details I wanted.
Material Design includes this cool new ripple animation. If you read more about it at Implementing Material Design in Your Android App, you see that you can get this behavior for free when you use ?android:selectableItemBackground
as your background.
If you are going to use the activated state, though, this is not an option. ?android:selectableItemBackground
does not support visualization for the activated state. You can try to roll your own with activated support using a state selector drawable, but that ends up looking like this:
The selected state responds each time you tap on it. So when you tap the view to turn activated state off, you also get the ripple effect.
This did not make sense to me visually. In my mind, the list has two modes: normal mode, and selection mode. In normal mode, a tap should have the same ripple effect that ?android:selectableItemBackground
gives me. In selection mode, though, a tap should simply toggle activated on and off, without any ripple effect. In Lollipop, it would be nice to also have a Material Design affordance: a state list animator to elevate the selected items up in translation.
To get this effect with the out of the box Android APIs, you have to do more than use state list drawables and animators judiciously. You need to actually have two different modes for the view: one in which it uses a default set of drawables & animators, and one in which it uses a different set exclusively for selection. Like this:
This is where the second tool I wrote comes into play: a ViewHolder subclass called SwappingHolder
, which does exactly what I just described. SwappingHolder subclasses the regular ViewHolder and adds six additional properties:
public Drawable getSelectionModeBackgroundDrawable();
public Drawable getDefaultModeBackgroundDrawable();
public StateListAnimator getSelectionModeStateListAnimator();
public StateListAnimator getDefaultModeStateListAnimator();
public boolean isSelectable();
public boolean isActivated();
When you first create it, SwappingHolder will leave its itemView
’s background drawable and state list animator alone, stashing those initial values in defaultModeBackgroundDrawable
and defaultModeStateListAnimator
. If you set selectable
to “true,” though, it will switch to the selectionMode
version of both those properties. Set selectable
back to “false,”” it switches back to the default value. And the activated property? It calls through to itemView’s activated property.
Out of the box, SwappingHolder uses a selectionModeStateListAnimator
that elevates the selected item up a little bit when activated, and a selectionModeBackgroundDrawable
that uses the colorAccent
attribute out of the appcompat
Material theme.
So that fixes that. The last bit is to hook everything up to the selection logic in a way that’s easy to turn on and off.
Again, you can do it by hand if you like. There are two steps: updating the ViewHolder when it is bound to a crime, and hooking up click events. To update when bound to a crime, add some more code to bindCrime()
:
private class CrimeHolder extends SwappingHolder {
...
public void bindCrime(Crime crime) {
mCrime = crime;
mSolvedCheckBox.setChecked(crime.isSolved());
setSelectable(mMultiSelector.isSelectable());
setActivated(mMultiSelector.isSelected(getPosition()));
}
}
So every time you hook up your ViewHolder to another crime, you need to double check and see whether you’re currently in the selection mode, and whether the item you’re hooking up to is selected.
And then hook up a click listener:
private class CrimeHolder extends SwappingHolder
implements View.OnClickListener {
...
public CrimeHolder(View itemView) {
super(itemView);
mSolvedCheckBox = (CheckBox) itemView
.findViewById(R.id.crime_list_item_solvedCheckBox);
itemView.setOnClickListener(this);
}
@Override
public void onClick(View view) {
if (mMultiSelector.isSelectable()) {
// Selection is active; toggle activation
setActivated(!isActivated());
mMultiSelector.setSelected(getPosition(), isActivated());
} else {
// Selection not active
}
}
}
For single select, the onClick()
implementation will need to be more complicated than that, because it will need to find the other currently active selection and deselect it.
This isn’t a whole lot of code, but it is boilerplate that you would need to implement every time you write this. I’ve done some more work in MultiSelector
that gets rid of the boilerplate.
Okay, the last step: turning it on and off. You definitely need to do this for CHOICE_MODE_MULTIPLE_MODAL
, and you often need to when using the other choice modes, too.
The simplest solution is to augment your setSelectable()
implementation with a notifyDataSetChanged()
:
public void setSelectable(boolean isSelectable) {
mIsSelectable = isSelectable;
mRecyclerView.getAdapter().notifyDataSetChanged();
}
In ListView (and in ViewPager), notifyDataSetChanged()
was almost always the right solution when you were showing the wrong thing. In RecyclerView, I recommend that you be much more judicious about using it.
Here’s why: the biggest reason to use RecyclerView is that it makes it easy to animate changes to your list content. For example, if you want to delete the first crime from your list, you can animate that like this:
// Delete the 0th crime from your model
mCrimes.remove(0);
// Notify the adapter that it was removed
mRecyclerView.getAdapter().notifyItemRemoved(0);
Calling notifyDataSetChanged()
can break that, because it interrupts those animations.
The RecyclerView’s ItemAnimator
will animate the change for you. The default animator will fade out item 0, then shift the other items up one.
What happens if you do notifyDataSetChanged()
soon after that? It will kill any pending animations, requery the adaptor and redisplay everything. A heavy hammer, that. Often it’s the right choice anyway, but be aware: if you can update your list content in some other way besides notifyDataSetChanged
, do it!
So what other way could we do this? Well… like this:
public void setSelectable(boolean isSelectable) {
mIsSelectable = isSelectable;
for (int i = 0; i < mRecyclerView.getAdapter().getItemCount(); i++) {
RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForPosition(i);
if (holder != null) {
((SwappingHolder)holder).setSelectable(isSelectable);
}
}
}
We can iterate over all the ViewHolders, cast them to SwappingHolder and manually tell them what the current selectable state is. Yech.
Like with SwappingHolder, MultiSelector takes care of this for you. MultiSelector knows which ViewHolders are hooked up, so this line is all you need to update your user interface:
mMultiSelector.setSelectable(true);
Once setSelectable()
is implemented, you can achieve the rest of CHOICE_MODE_MULTIPLE_MODAL
by using a regular ActionMode.Callback
. Call through to your setSelectable()
from within the relevant callback methods:
private ActionMode.Callback mDeleteMode = new ActionMode.Callback() {
@Override
public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) {
setSelectable(true);
return false;
}
@Override
public void onDestroyActionMode(ActionMode actionMode) {
setSelectable(false);
}
@Override
public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { ... }
@Override
public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { ... }
}
Then use a long click listener to turn on the action mode:
private class CrimeHolder extends SwappingHolder
implements View.OnClickListener, View.OnLongClickListener {
...
public CrimeHolder(View itemView) {
...
itemView.setOnClickListener(this);
itemView.setOnLongClickListener(this);
itemView.setLongClickable(true);
}
@Override
public boolean onLongClick(View v) {
ActionBarActivity activity = (ActionBarActivity)getActivity();
activity.startSupportActionMode(deleteMode);
setSelected(this, true);
return true;
}
}
Ok, so that’s everything going on in MultiSelect. What if you don’t care, and would prefer to have an out-of-the-box solution?
One existing solution has been brought to my attention: TwoWayView, a library by Lucas Rocha. I haven’t had time to research the details of how it does what it does, but I can tell you that it sets out to replicate the setChoiceMode()
API used by ListView, as well as a lot of other stuff that ListView had. For folks looking to drop-in replace their old ListView with a RecyclerView-based implementation, TwoWayView looks like a great solution. If you’d like to use that, I defer to their documentation.
Of course, by the time my colleagues told me about this, I had already written my own multiselect implementation that looked much different. Maybe you will find it useful, too. I’ve tried to make something small, focused, flexible and easy to use. There’s not a lot of code, and only a limited amount of judiciously chosen “magic.” Here’s how it works.
First, import the library. Add the following line to your build.gradle
:
compile 'com.bignerdranch.android:recyclerview-multiselect:+'
(You can find the project on GitHub, along with the Javadocs.)
Next, create a MultiSelector instance. In my example app, I did it inside my Fragment:
public class CrimeListFragment extends Fragment {
private MultiSelector mMultiSelector = new MultiSelector();
...
}
The MultiSelector knows which items are selected, and is also your interface for controlling item selection across everything it is hooked up to. In this case, that’s everything in the adapter.
To hook up a SwappingHolder to a MultiSelector, pass in the MultiSelector in the constructor, and use click listeners to call through to MultiSelector.tapSelection()
:
private class CrimeHolder extends SwappingHolder
implements View.OnClickListener, View.OnLongClickListener {
private final CheckBox mSolvedCheckBox;
private Crime mCrime;
public CrimeHolder(View itemView) {
super(itemView, mMultiSelector);
mSolvedCheckBox = (CheckBox) itemView.findViewById(R.id.crime_list_item_solvedCheckBox);
itemView.setOnClickListener(this);
}
@Override
public void onClick(View v) {
if (mCrime == null) {
return;
}
if (!mMultiSelector.tapSelection(this)) {
// start an instance of CrimePagerActivity
Intent i = new Intent(getActivity(), CrimePagerActivity.class);
i.putExtra(CrimeFragment.EXTRA_CRIME_ID, c.getId());
startActivity(i);
}
}
}
MultiSelector.tapSelection()
simulates tapping a selected item; if the MultiSelector is in selection mode, it returns true and toggles the selection for that item. If not, it returns false and does nothing.
To turn on multiselect mode, call setSelectable(true)
:
mMultiSelector.setSelectable(true);
This will toggle the flag on the MultiSelector, and toggle it on all its bound SwappingHolders, too. This is all done for you by SwappingHolder—it extends MultiSelectorBindingHolder
, which binds itself to your MultiSelector.
And for basic multiselect, that’s all there is to it. When you need to know whether an item is selected, ask the multiselector:
for (int i = mCrimes.size(); i > 0; i--) {
if (mMultiSelector.isSelected(i, 0)) {
Crime crime = mCrimes.get(i);
CrimeLab.get(getActivity()).deleteCrime(crime);
mRecyclerView.getAdapter().notifyItemRemoved(i);
}
}
To use single selection instead of multiselect, use SingleSelector
instead of MultiSelector
:
public class CrimeListFragment extends Fragment {
private MultiSelector mMultiSelector = new SingleSelector();
...
}
To get the same effect as CHOICE_MODE_MULTIPLE_MODAL
, you can either write your own ActionMode.Callback
as described above, or use the provided abstract implementation, ModalMultiSelectorCallback
:
private ActionMode.Callback mDeleteMode = new ModalMultiSelectorCallback(mMultiSelector) {
@Override
public boolean onCreateActionMode(ActionMode actionMode, Menu menu) {
getActivity().getMenuInflater().inflate(R.menu.crime_list_item_context, menu);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) {
switch (menuItem.getItemId()) {
case R.id.menu_item_delete_crime:
// Delete crimes from model
mMultiSelector.clearSelections();
return true;
default:
break;
}
return false;
}
};
ModalMultiSelectorCallback
will call MultiSelector.setSelectable(true)
and clearSelections()
inside onPrepareActionMode
, and setSelectable(false)
in onDestroyActionMode
. Kick it off like any other action mode inside a long click listener:
private class CrimeHolder extends SwappingHolder
implements View.OnClickListener, View.OnLongClickListener {
public CrimeHolder(View itemView) {
...
itemView.setOnLongClickListener(this);
itemView.setLongClickable(true);
}
@Override
public boolean onLongClick(View v) {
ActionBarActivity activity = (ActionBarActivity)getActivity();
activity.startSupportActionMode(mDeleteMode);
mMultiSelector.setSelected(this, true);
return true;
}
}
SwappingDrawable uses two sets of drawables and state list animators for its itemView: one while in the default mode, and one while in selection mode. You can customize these by calling one of the various setters:
public void setSelectionModeBackgroundDrawable(Drawable drawable);
public void setDefaultModeBackgroundDrawable(Drawable drawable);
public void setSelectionModeStateListAnimator(int resId);
public void setDefaultModeStateListAnimator(int resId);
The state list animator setters are safe to call prior to API 21, and will result in a no-op.
If you need to customize what the selected states look like beyond what SwappingHolder offers, you can extend the MultiSelectorBindingHolder
abstract class:
public class MyCustomHolder extends MultiSelectorBindingHolder {
@Override
public void setSelectable(boolean selectable) { ... }
@Override
public boolean isSelectable() { ... }
@Override
public void setActivated(boolean activated) { ... }
@Override
public boolean isActivated() { ... }
}
And if that’s still too restrictive, you can implement the SelectableHolder
interface instead, which provides the same methods. It requires a bit more code: you will need to bind your ViewHolder to the MultiSelector by calling mMultiSelector.bindHolder()
every time onBindViewHolder is called.
In this post, we took a look at selecting items in a RecyclerView. From working along, you now know how to show which views are checked and unchecked, track checked and unchecked states for items in the list, and turn the whole thing off and on in a contextual action mode.
Introduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Jetpack Compose is a declarative framework for building native Android UI recommended by Google. To simplify and accelerate UI development, the framework turns the...
Big Nerd Ranch is chock-full of incredibly talented people. Today, we’re starting a series, Tell Our BNR Story, where folks within our industry share...