Skip to content

Instantly share code, notes, and snippets.

@cbeyls
Last active October 28, 2024 18:23
Show Gist options
  • Save cbeyls/32b2b7a33caee29e97bfe81a949ba247 to your computer and use it in GitHub Desktop.
Save cbeyls/32b2b7a33caee29e97bfe81a949ba247 to your computer and use it in GitHub Desktop.
Helper class to reproduce ListView's modal MultiChoice mode with a RecyclerView. Compatible with API 7+.
package be.digitalia.common.widgets;
import android.content.Context;
import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import android.support.v4.util.LongSparseArray;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.view.ActionMode;
import android.support.v7.widget.RecyclerView;
import android.util.SparseBooleanArray;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Checkable;
/**
* Helper class to reproduce ListView's modal MultiChoice mode with a RecyclerView.
* Compatible with API 7+.
* Declare and use this class from inside your Adapter.
*
* @author Christophe Beyls
*/
public class MultiChoiceHelper {
/**
* A handy ViewHolder base class which works with the MultiChoiceHelper
* and reproduces the default behavior of a ListView.
*/
public static abstract class ViewHolder extends RecyclerView.ViewHolder {
View.OnClickListener clickListener;
MultiChoiceHelper multiChoiceHelper;
public ViewHolder(View itemView) {
super(itemView);
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
if (isMultiChoiceActive()) {
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
multiChoiceHelper.toggleItemChecked(position, false);
updateCheckedState(position);
}
} else {
if (clickListener != null) {
clickListener.onClick(view);
}
}
}
});
itemView.setOnLongClickListener(new View.OnLongClickListener() {
@Override
public boolean onLongClick(View view) {
if ((multiChoiceHelper == null) || isMultiChoiceActive()) {
return false;
}
int position = getAdapterPosition();
if (position != RecyclerView.NO_POSITION) {
multiChoiceHelper.setItemChecked(position, true, false);
updateCheckedState(position);
}
return true;
}
});
}
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);
}
}
public void setOnClickListener(View.OnClickListener clickListener) {
this.clickListener = clickListener;
}
public void bind(MultiChoiceHelper multiChoiceHelper, int position) {
this.multiChoiceHelper = multiChoiceHelper;
if (multiChoiceHelper != null) {
updateCheckedState(position);
}
}
public boolean isMultiChoiceActive() {
return (multiChoiceHelper != null) && (multiChoiceHelper.getCheckedItemCount() > 0);
}
}
public interface MultiChoiceModeListener extends ActionMode.Callback {
/**
* Called when an item is checked or unchecked during selection mode.
*
* @param mode The {@link ActionMode} providing the selection startSupportActionModemode
* @param position Adapter position of the item that was checked or unchecked
* @param id Adapter ID of the item that was checked or unchecked
* @param checked <code>true</code> if the item is now checked, <code>false</code>
* if the item is now unchecked.
*/
void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked);
}
private static final int CHECK_POSITION_SEARCH_DISTANCE = 20;
private final AppCompatActivity activity;
private final RecyclerView.Adapter adapter;
private SparseBooleanArray checkStates;
private LongSparseArray<Integer> checkedIdStates;
private int checkedItemCount = 0;
private MultiChoiceModeWrapper multiChoiceModeCallback;
ActionMode choiceActionMode;
/**
* Make sure this constructor is called before setting the adapter on the RecyclerView
* so this class will be notified before the RecyclerView in case of data set changes.
*/
public MultiChoiceHelper(@NonNull AppCompatActivity activity, @NonNull RecyclerView.Adapter adapter) {
this.activity = activity;
this.adapter = adapter;
adapter.registerAdapterDataObserver(new AdapterDataSetObserver());
checkStates = new SparseBooleanArray(0);
if (adapter.hasStableIds()) {
checkedIdStates = new LongSparseArray<>(0);
}
}
public Context getContext() {
return activity;
}
public void setMultiChoiceModeListener(MultiChoiceModeListener listener) {
if (listener == null) {
multiChoiceModeCallback = null;
return;
}
if (multiChoiceModeCallback == null) {
multiChoiceModeCallback = new MultiChoiceModeWrapper();
}
multiChoiceModeCallback.setWrapped(listener);
}
public int getCheckedItemCount() {
return checkedItemCount;
}
public boolean isItemChecked(int position) {
return checkStates.get(position);
}
public SparseBooleanArray getCheckedItemPositions() {
return checkStates;
}
public long[] getCheckedItemIds() {
final LongSparseArray<Integer> idStates = checkedIdStates;
if (idStates == null) {
return new long[0];
}
final int count = idStates.size();
final long[] ids = new long[count];
for (int i = 0; i < count; i++) {
ids[i] = idStates.keyAt(i);
}
return ids;
}
public void clearChoices() {
if (checkedItemCount > 0) {
final int start = checkStates.keyAt(0);
final int end = checkStates.keyAt(checkStates.size() - 1);
checkStates.clear();
if (checkedIdStates != null) {
checkedIdStates.clear();
}
checkedItemCount = 0;
adapter.notifyItemRangeChanged(start, end - start + 1);
if (choiceActionMode != null) {
choiceActionMode.finish();
}
}
}
public void setItemChecked(int position, boolean value, boolean notifyChanged) {
// Start selection mode if needed. We don't need to if we're unchecking something.
if (value) {
startSupportActionModeIfNeeded();
}
boolean oldValue = checkStates.get(position);
checkStates.put(position, value);
if (oldValue != value) {
final long id = adapter.getItemId(position);
if (checkedIdStates != null) {
if (value) {
checkedIdStates.put(id, position);
} else {
checkedIdStates.delete(id);
}
}
if (value) {
checkedItemCount++;
} else {
checkedItemCount--;
}
if (notifyChanged) {
adapter.notifyItemChanged(position);
}
if (choiceActionMode != null) {
multiChoiceModeCallback.onItemCheckedStateChanged(choiceActionMode, position, id, value);
if (checkedItemCount == 0) {
choiceActionMode.finish();
}
}
}
}
public void toggleItemChecked(int position, boolean notifyChanged) {
setItemChecked(position, !isItemChecked(position), notifyChanged);
}
public Parcelable onSaveInstanceState() {
SavedState savedState = new SavedState();
savedState.checkedItemCount = checkedItemCount;
savedState.checkStates = clone(checkStates);
if (checkedIdStates != null) {
savedState.checkedIdStates = checkedIdStates.clone();
}
return savedState;
}
private static SparseBooleanArray clone(SparseBooleanArray original) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return original.clone();
}
final int size = original.size();
SparseBooleanArray clone = new SparseBooleanArray(size);
for (int i = 0; i < size; ++i) {
clone.append(original.keyAt(i), original.valueAt(i));
}
return clone;
}
public void onRestoreInstanceState(Parcelable state) {
if ((state != null) && (checkedItemCount == 0)) {
SavedState savedState = (SavedState) state;
checkedItemCount = savedState.checkedItemCount;
checkStates = savedState.checkStates;
checkedIdStates = savedState.checkedIdStates;
if (checkedItemCount > 0) {
// Empty adapter is given a chance to be populated before completeRestoreInstanceState()
if (adapter.getItemCount() > 0) {
confirmCheckedPositions();
}
activity.getWindow().getDecorView().post(new Runnable() {
@Override
public void run() {
completeRestoreInstanceState();
}
});
}
}
}
void completeRestoreInstanceState() {
if (checkedItemCount > 0) {
if (adapter.getItemCount() == 0) {
// Adapter was not populated, clear the selection
confirmCheckedPositions();
} else {
startSupportActionModeIfNeeded();
}
}
}
private void startSupportActionModeIfNeeded() {
if (choiceActionMode == null) {
if (multiChoiceModeCallback == null) {
throw new IllegalStateException("No callback set");
}
choiceActionMode = activity.startSupportActionMode(multiChoiceModeCallback);
}
}
public static class SavedState implements Parcelable {
int checkedItemCount;
SparseBooleanArray checkStates;
LongSparseArray<Integer> checkedIdStates;
SavedState() {
}
SavedState(Parcel in) {
checkedItemCount = in.readInt();
checkStates = in.readSparseBooleanArray();
final int n = in.readInt();
if (n >= 0) {
checkedIdStates = new LongSparseArray<>(n);
for (int i = 0; i < n; i++) {
final long key = in.readLong();
final int value = in.readInt();
checkedIdStates.append(key, value);
}
}
}
@Override
public void writeToParcel(Parcel out, int flags) {
out.writeInt(checkedItemCount);
out.writeSparseBooleanArray(checkStates);
final int n = checkedIdStates != null ? checkedIdStates.size() : -1;
out.writeInt(n);
for (int i = 0; i < n; i++) {
out.writeLong(checkedIdStates.keyAt(i));
out.writeInt(checkedIdStates.valueAt(i));
}
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<SavedState> CREATOR = new Creator<SavedState>() {
@Override
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
@Override
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
void confirmCheckedPositions() {
if (checkedItemCount == 0) {
return;
}
final int itemCount = adapter.getItemCount();
boolean checkedCountChanged = false;
if (itemCount == 0) {
// Optimized path for empty adapter: remove all items.
checkStates.clear();
if (checkedIdStates != null) {
checkedIdStates.clear();
}
checkedItemCount = 0;
checkedCountChanged = true;
} else if (checkedIdStates != null) {
// Clear out the positional check states, we'll rebuild it below from IDs.
checkStates.clear();
for (int checkedIndex = 0; checkedIndex < checkedIdStates.size(); checkedIndex++) {
final long id = checkedIdStates.keyAt(checkedIndex);
final int lastPos = checkedIdStates.valueAt(checkedIndex);
if ((lastPos >= itemCount) || (id != adapter.getItemId(lastPos))) {
// Look around to see if the ID is nearby. If not, uncheck it.
final int start = Math.max(0, lastPos - CHECK_POSITION_SEARCH_DISTANCE);
final int end = Math.min(lastPos + CHECK_POSITION_SEARCH_DISTANCE, itemCount);
boolean found = false;
for (int searchPos = start; searchPos < end; searchPos++) {
final long searchId = adapter.getItemId(searchPos);
if (id == searchId) {
found = true;
checkStates.put(searchPos, true);
checkedIdStates.setValueAt(checkedIndex, searchPos);
break;
}
}
if (!found) {
checkedIdStates.delete(id);
checkedIndex--;
checkedItemCount--;
checkedCountChanged = true;
if (choiceActionMode != null && multiChoiceModeCallback != null) {
multiChoiceModeCallback.onItemCheckedStateChanged(choiceActionMode, lastPos, id, false);
}
}
} else {
checkStates.put(lastPos, true);
}
}
} else {
// If the total number of items decreased, remove all out-of-range check indexes.
for (int i = checkStates.size() - 1; (i >= 0) && (checkStates.keyAt(i) >= itemCount); i--) {
if (checkStates.valueAt(i)) {
checkedItemCount--;
checkedCountChanged = true;
}
checkStates.delete(checkStates.keyAt(i));
}
}
if (checkedCountChanged && choiceActionMode != null) {
if (checkedItemCount == 0) {
choiceActionMode.finish();
} else {
choiceActionMode.invalidate();
}
}
}
class AdapterDataSetObserver extends RecyclerView.AdapterDataObserver {
@Override
public void onChanged() {
confirmCheckedPositions();
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
confirmCheckedPositions();
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
confirmCheckedPositions();
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
confirmCheckedPositions();
}
}
class MultiChoiceModeWrapper implements MultiChoiceModeListener {
private MultiChoiceModeListener wrapped;
public void setWrapped(@NonNull MultiChoiceModeListener wrapped) {
this.wrapped = wrapped;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
return wrapped.onCreateActionMode(mode, menu);
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return wrapped.onPrepareActionMode(mode, menu);
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
return wrapped.onActionItemClicked(mode, item);
}
@Override
public void onDestroyActionMode(ActionMode mode) {
wrapped.onDestroyActionMode(mode);
choiceActionMode = null;
clearChoices();
}
@Override
public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
wrapped.onItemCheckedStateChanged(mode, position, id, checked);
}
}
}
@RajeevNakka
Copy link

Hi @cbeyls , I have a small doubt. When I need to remove a checked Item from recyclerview, do I need to set its state to unchecked before deleting? Because when I don't do that, the item is getting deleted but CAB is still showing though there are no selected items after deletion.

@RajeevNakka
Copy link

Hi @cbeyls won't it handle dataset changes when we don't have stable Ids? I mean when an item gets deleted from the list it should recalculate all the checked positions right? (If I delete 4th item it should decrease values of checked positions whose value is greater than 4). I can do this manually I am just asking.

@cbeyls
Copy link
Author

cbeyls commented Feb 21, 2017

Hello @RajeevNakka
In reply to your 2 questions:

  1. When items are deleted from your data set, you don't need to do anything special besides callling adapter.notifyDataSetChanged() or adapter.notifyItemRemoved(). When you notify the adapter, confirmCheckedPositions() is called. If you look at the code of this method you'll see that it will rebuild the selection and if the selection is now empty the ActionMode will close automatically. Also, don't forget to register the MultiChoiceHelper as listener for your adapter before setting the adapter on the RecyclerView (as explained on Medium).
  2. If you don't have stable ids then indeed when you insert or delete items while the actionMode is still open, the selection will not move (just be shrunk) and the incorrect items will become selected. This could be improved by adding more code in the AdapterDataSetObserver to rebuild selection using the position and size of the inserted/deleted range. I mainly wanted to optimize this class for stable ids but I could add this code later. But: if you are deleting items in response to user action, you should simply call ActionMode.finish() when the user presses the delete button. That will close the actionMode immediately and clear all selections.

@cbeyls
Copy link
Author

cbeyls commented Feb 25, 2017

You can see a real world example of how I use this class here: BookmarksAdapter.java.

@m7mdra
Copy link

m7mdra commented May 8, 2017

hello @cbeyls
is there anyway provided by the helper class to highlight the selected item ?
thanks in advance.

@cbeyls
Copy link
Author

cbeyls commented May 21, 2017

Hi @m7mdra to highlight the selected items you should use a selector drawable for the background of the item views: android:state_activated or android:state_checked depending on the Android versions you target and if the root item view implements Checkable or not.

@Synytsia
Copy link

Synytsia commented Nov 8, 2017

Good day!
How can I get access to Activity in my Adapter.class in this constructor?
Make sure this constructor is called before setting the adapter on the RecyclerView

  • so this class will be notified before the RecyclerView in case of data set changes.
    */
    public MultiChoiseHelper(@nonnull AppCompatActivity activity, @nonnull RecyclerView.Adapter adapter) {
    this.activity = activity;
    this.adapter = adapter;
    adapter.registerAdapterDataObserver(new AdapterDataSetObserver());
    checkStates = new SparseBooleanArray(0);
    if (adapter.hasStableIds()) {
    checkedIdStates = new LongSparseArray<>(0);
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment