Last active
October 28, 2024 18:23
-
-
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+.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
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
Hi @m7mdra to highlight the selected items you should use a selector drawable for the background of the item views:
android:state_activated
orandroid:state_checked
depending on the Android versions you target and if the root item view implementsCheckable
or not.