package com.marshalchen.common.uimodule.twowayview; import android.annotation.TargetApi; import android.os.Build; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.util.LongSparseArray; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.util.SparseBooleanArray; import android.view.View; import android.widget.Checkable; import com.marshalchen.common.uimodule.R; import static android.os.Build.VERSION_CODES.HONEYCOMB; public class ItemSelectionSupport { public static final int INVALID_POSITION = -1; public static enum ChoiceMode { NONE, SINGLE, MULTIPLE } private final RecyclerView mRecyclerView; private final TouchListener mTouchListener; private ChoiceMode mChoiceMode = ChoiceMode.NONE; private CheckedStates mCheckedStates; private CheckedIdStates mCheckedIdStates; private int mCheckedCount; private static final String STATE_KEY_CHOICE_MODE = "choiceMode"; private static final String STATE_KEY_CHECKED_STATES = "checkedStates"; private static final String STATE_KEY_CHECKED_ID_STATES = "checkedIdStates"; private static final String STATE_KEY_CHECKED_COUNT = "checkedCount"; private static final int CHECK_POSITION_SEARCH_DISTANCE = 20; private ItemSelectionSupport(RecyclerView recyclerView) { mRecyclerView = recyclerView; mTouchListener = new TouchListener(recyclerView); recyclerView.addOnItemTouchListener(mTouchListener); } private void updateOnScreenCheckedViews() { final int count = mRecyclerView.getChildCount(); for (int i = 0; i < count; i++) { final View child = mRecyclerView.getChildAt(i); final int position = mRecyclerView.getChildPosition(child); setViewChecked(child, mCheckedStates.get(position)); } } /** * Returns the number of items currently selected. This will only be valid * if the choice mode is not {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE} (default). * * <p>To determine the specific items that are currently selected, use one of * the <code>getChecked*</code> methods. * * @return The number of items currently selected * * @see #getCheckedItemPosition() * @see #getCheckedItemPositions() * @see #getCheckedItemIds() */ public int getCheckedItemCount() { return mCheckedCount; } /** * Returns the checked state of the specified position. The result is only * valid if the choice mode has been set to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#SINGLE} * or {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#MULTIPLE}. * * @param position The item whose checked state to return * @return The item's checked state or <code>false</code> if choice mode * is invalid * * @see #setChoiceMode(com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode) */ public boolean isItemChecked(int position) { if (mChoiceMode != ChoiceMode.NONE && mCheckedStates != null) { return mCheckedStates.get(position); } return false; } /** * Returns the currently checked item. The result is only valid if the choice * mode has been set to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#SINGLE}. * * @return The position of the currently checked item or * {@link #INVALID_POSITION} if nothing is selected * * @see #setChoiceMode(com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode) */ public int getCheckedItemPosition() { if (mChoiceMode == ChoiceMode.SINGLE && mCheckedStates != null && mCheckedStates.size() == 1) { return mCheckedStates.keyAt(0); } return INVALID_POSITION; } /** * Returns the set of checked items in the list. The result is only valid if * the choice mode has not been set to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE}. * * @return A SparseBooleanArray which will return true for each call to * get(int position) where position is a position in the list, * or <code>null</code> if the choice mode is set to * {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE}. */ public SparseBooleanArray getCheckedItemPositions() { if (mChoiceMode != ChoiceMode.NONE) { return mCheckedStates; } return null; } /** * Returns the set of checked items ids. The result is only valid if the * choice mode has not been set to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE} and the adapter * has stable IDs. * * @return A new array which contains the id of each checked item in the * list. * * @see android.support.v7.widget.RecyclerView.Adapter#hasStableIds() */ public long[] getCheckedItemIds() { if (mChoiceMode == ChoiceMode.NONE || mCheckedIdStates == null || mRecyclerView.getAdapter() == null) { return new long[0]; } final int count = mCheckedIdStates.size(); final long[] ids = new long[count]; for (int i = 0; i < count; i++) { ids[i] = mCheckedIdStates.keyAt(i); } return ids; } /** * Sets the checked state of the specified position. The is only valid if * the choice mode has been set to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#SINGLE} or * {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#MULTIPLE}. * * @param position The item whose checked state is to be checked * @param checked The new checked state for the item */ public void setItemChecked(int position, boolean checked) { if (mChoiceMode == ChoiceMode.NONE) { return; } final Adapter adapter = mRecyclerView.getAdapter(); if (mChoiceMode == ChoiceMode.MULTIPLE) { boolean oldValue = mCheckedStates.get(position); mCheckedStates.put(position, checked); if (mCheckedIdStates != null && adapter.hasStableIds()) { if (checked) { mCheckedIdStates.put(adapter.getItemId(position), position); } else { mCheckedIdStates.delete(adapter.getItemId(position)); } } if (oldValue != checked) { if (checked) { mCheckedCount++; } else { mCheckedCount--; } } } else { boolean updateIds = mCheckedIdStates != null && adapter.hasStableIds(); // Clear all values if we're checking something, or unchecking the currently // selected item if (checked || isItemChecked(position)) { mCheckedStates.clear(); if (updateIds) { mCheckedIdStates.clear(); } } // This may end up selecting the checked we just cleared but this way // we ensure length of mCheckStates is 1, a fact getCheckedItemPosition relies on if (checked) { mCheckedStates.put(position, true); if (updateIds) { mCheckedIdStates.put(adapter.getItemId(position), position); } mCheckedCount = 1; } else if (mCheckedStates.size() == 0 || !mCheckedStates.valueAt(0)) { mCheckedCount = 0; } } updateOnScreenCheckedViews(); } @TargetApi(HONEYCOMB) public void setViewChecked(View view, boolean checked) { if (view instanceof Checkable) { ((Checkable) view).setChecked(checked); } else if (Build.VERSION.SDK_INT >= HONEYCOMB) { view.setActivated(checked); } } /** * Clears any choices previously set. */ public void clearChoices() { if (mCheckedStates != null) { mCheckedStates.clear(); } if (mCheckedIdStates != null) { mCheckedIdStates.clear(); } mCheckedCount = 0; updateOnScreenCheckedViews(); } /** * Returns the current choice mode. * * @see #setChoiceMode(com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode) */ public ChoiceMode getChoiceMode() { return mChoiceMode; } /** * Defines the choice behavior for the List. By default, Lists do not have any choice behavior * ({@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE}). By setting the choiceMode to {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#SINGLE}, the * List allows up to one item to be in a chosen state. By setting the choiceMode to * {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#MULTIPLE}, the list allows any number of items to be chosen. * * @param choiceMode One of {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#NONE}, {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#SINGLE}, or * {@link com.marshalchen.common.uimodule.twowayview.ItemSelectionSupport.ChoiceMode#MULTIPLE} */ public void setChoiceMode(ChoiceMode choiceMode) { if (mChoiceMode == choiceMode) { return; } mChoiceMode = choiceMode; if (mChoiceMode != ChoiceMode.NONE) { if (mCheckedStates == null) { mCheckedStates = new CheckedStates(); } final Adapter adapter = mRecyclerView.getAdapter(); if (mCheckedIdStates == null && adapter != null && adapter.hasStableIds()) { mCheckedIdStates = new CheckedIdStates(); } } } public void onAdapterDataChanged() { final Adapter adapter = mRecyclerView.getAdapter(); if (mChoiceMode == ChoiceMode.NONE || adapter == null || !adapter.hasStableIds()) { return; } final int itemCount = adapter.getItemCount(); // Clear out the positional check states, we'll rebuild it below from IDs. mCheckedStates.clear(); for (int checkedIndex = 0; checkedIndex < mCheckedIdStates.size(); checkedIndex++) { final long currentId = mCheckedIdStates.keyAt(checkedIndex); final int currentPosition = mCheckedIdStates.valueAt(checkedIndex); final long newPositionId = adapter.getItemId(currentPosition); if (currentId != newPositionId) { // Look around to see if the ID is nearby. If not, uncheck it. final int start = Math.max(0, currentPosition - CHECK_POSITION_SEARCH_DISTANCE); final int end = Math.min(currentPosition + CHECK_POSITION_SEARCH_DISTANCE, itemCount); boolean found = false; for (int searchPos = start; searchPos < end; searchPos++) { final long searchId = adapter.getItemId(searchPos); if (currentId == searchId) { found = true; mCheckedStates.put(searchPos, true); mCheckedIdStates.setValueAt(checkedIndex, searchPos); break; } } if (!found) { mCheckedIdStates.delete(currentId); mCheckedCount--; checkedIndex--; } } else { mCheckedStates.put(currentPosition, true); } } } public Bundle onSaveInstanceState() { final Bundle state = new Bundle(); state.putInt(STATE_KEY_CHOICE_MODE, mChoiceMode.ordinal()); state.putParcelable(STATE_KEY_CHECKED_STATES, mCheckedStates); state.putParcelable(STATE_KEY_CHECKED_ID_STATES, mCheckedIdStates); state.putInt(STATE_KEY_CHECKED_COUNT, mCheckedCount); return state; } public void onRestoreInstanceState(Bundle state) { mChoiceMode = ChoiceMode.values()[state.getInt(STATE_KEY_CHOICE_MODE)]; mCheckedStates = state.getParcelable(STATE_KEY_CHECKED_STATES); mCheckedIdStates = state.getParcelable(STATE_KEY_CHECKED_ID_STATES); mCheckedCount = state.getInt(STATE_KEY_CHECKED_COUNT); // TODO confirm ids here } public static ItemSelectionSupport addTo(RecyclerView recyclerView) { ItemSelectionSupport itemSelectionSupport = from(recyclerView); if (itemSelectionSupport == null) { itemSelectionSupport = new ItemSelectionSupport(recyclerView); recyclerView.setTag(R.id.twowayview_item_selection_support, itemSelectionSupport); } else { // TODO: Log warning } return itemSelectionSupport; } public static void removeFrom(RecyclerView recyclerView) { final ItemSelectionSupport itemSelection = from(recyclerView); if (itemSelection == null) { // TODO: Log warning return; } itemSelection.clearChoices(); recyclerView.removeOnItemTouchListener(itemSelection.mTouchListener); recyclerView.setTag(R.id.twowayview_item_selection_support, null); } public static ItemSelectionSupport from(RecyclerView recyclerView) { if (recyclerView == null) { return null; } return (ItemSelectionSupport) recyclerView.getTag(R.id.twowayview_item_selection_support); } private static class CheckedStates extends SparseBooleanArray implements Parcelable { private static final int FALSE = 0; private static final int TRUE = 1; public CheckedStates() { super(); } private CheckedStates(Parcel in) { final int size = in.readInt(); if (size > 0) { for (int i = 0; i < size; i++) { final int key = in.readInt(); final boolean value = (in.readInt() == TRUE); put(key, value); } } } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel parcel, int flags) { final int size = size(); parcel.writeInt(size); for (int i = 0; i < size; i++) { parcel.writeInt(keyAt(i)); parcel.writeInt(valueAt(i) ? TRUE : FALSE); } } public static final Creator<CheckedStates> CREATOR = new Creator<CheckedStates>() { @Override public CheckedStates createFromParcel(Parcel in) { return new CheckedStates(in); } @Override public CheckedStates[] newArray(int size) { return new CheckedStates[size]; } }; } private static class CheckedIdStates extends LongSparseArray<Integer> implements Parcelable { public CheckedIdStates() { super(); } private CheckedIdStates(Parcel in) { final int size = in.readInt(); if (size > 0) { for (int i = 0; i < size; i++) { final long key = in.readLong(); final int value = in.readInt(); put(key, value); } } } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel parcel, int flags) { final int size = size(); parcel.writeInt(size); for (int i = 0; i < size; i++) { parcel.writeLong(keyAt(i)); parcel.writeInt(valueAt(i)); } } public static final Creator<CheckedIdStates> CREATOR = new Creator<CheckedIdStates>() { @Override public CheckedIdStates createFromParcel(Parcel in) { return new CheckedIdStates(in); } @Override public CheckedIdStates[] newArray(int size) { return new CheckedIdStates[size]; } }; } private class TouchListener extends ClickItemTouchListener { TouchListener(RecyclerView recyclerView) { super(recyclerView); } @Override boolean performItemClick(RecyclerView parent, View view, int position, long id) { final Adapter adapter = mRecyclerView.getAdapter(); boolean checkedStateChanged = false; if (mChoiceMode == ChoiceMode.MULTIPLE) { boolean checked = !mCheckedStates.get(position, false); mCheckedStates.put(position, checked); if (mCheckedIdStates != null && adapter.hasStableIds()) { if (checked) { mCheckedIdStates.put(adapter.getItemId(position), position); } else { mCheckedIdStates.delete(adapter.getItemId(position)); } } if (checked) { mCheckedCount++; } else { mCheckedCount--; } checkedStateChanged = true; } else if (mChoiceMode == ChoiceMode.SINGLE) { boolean checked = !mCheckedStates.get(position, false); if (checked) { mCheckedStates.clear(); mCheckedStates.put(position, true); if (mCheckedIdStates != null && adapter.hasStableIds()) { mCheckedIdStates.clear(); mCheckedIdStates.put(adapter.getItemId(position), position); } mCheckedCount = 1; } else if (mCheckedStates.size() == 0 || !mCheckedStates.valueAt(0)) { mCheckedCount = 0; } checkedStateChanged = true; } if (checkedStateChanged) { updateOnScreenCheckedViews(); } return false; } @Override boolean performItemLongClick(RecyclerView parent, View view, int position, long id) { return true; } } }