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);
}
}
}
@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