一位非常有名的人曾经说过,
此生的事情永远比后世还容易。因为,此生自己做主。
这是真的吗?或许这值的去讨论。当去选择RecyclerView中的item时,虽然你实际上是操作自己:RecyclerView并没有给你相关的工具去做这件事 。所以,我们应该怎么去实现它?
我想说如果你按我的方法做会很简单,现在开始。下面是我研究发现的。
(如果你喜欢,你可以看完整的项目,在这里GitHub repo。如果你只想很快的去使用它,可以跳过前面的部分,直接阅读后面的“TL;DR”)
回顾:选择模式和上下文操作模式(Chocie Modes和Contextual Action Modes)
我打算实现像Android Programming书中CriminalIntent应用中的多项选择那样的效果:通过一个上下文操作模式。下面就是它的代码实现(为了方便展示,我只展示有趣的部分——当然你可以在这里找到所有的代码):
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中有模式选择的概念。如果ListView在一个特定的选择模式,它会通过一个显示的复选接口处理所有细节,一直跟踪检测标记和当单个item被点击时触发切换。像上面看到的那样,你通过调用ListView.setChoiceMode()来选择模式。通过ListView.isItemChecked(int)来检测item是否被先中(像你在onActionItemClicked看到的那样)。
当使用了CHOICE_MODE_MULTIPLE_MODAL,你长按list中的任何item都会自动启动多选择模式。同时,它将激活一个代表多选择交互的操作(Action)模式。上面的MultiChoiceModeListener是一个上下文操作模式的监听器——它像是一个只服务于这种模式的选择回调模式集合。
在上一篇文章中,我们知道了RecyclerView让我们自己实现所有的这些。所以,你需要实现三个部分。
- 显示哪个视图被选择了
- 监视list中所有item的被选择和未被选择状态
- 在上下文操作模式中控制
在一个完美的世界中,将会有一些事情是你在现实世界中实际想做的。当我写这这个时候,我发现了我解决办法的缺陷。我可以想像某人在阅读这篇文章时,摇头说:“这是认真的吗?我需要自己每次实现所有这些?”
所以在这篇文章中我将解释详细,从而可以你自己轻松实现如果你需要的话。同样,我提供了一个叫做MultiSelector 的包,这是一个最直接的解决方案。
保持跟踪状态
这是最直接的,所以我们先解决它。在ListView,它是这样实现的:
// Check item 0 mListView.setItemChecked(0, true); // Returns true mListView.isItemChecked(0); // Says what the choice mode currently is mListView.getChoiceMode();
我们自己的实现是这样子的:
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; }
现在程序不会像ListView.setItemChecked()那样更新用户接口,但它现在将会那样做。
当然,你可以用自己喜欢的方式去追踪。对象集合是一个不错的选择。
我把这个想法放到一个叫做MultiSelector的对象中:
MultiSelector selector = new MultiSelector(); selector.setSelected(0, true); selector.isSelected(0); selector.setSelectable(true); selector.isSelectable();
显示选项状态
ListView从Honeycomb开始,item选择就已经像这样可视化了:当一个item被选中时,视图就会通过调用setActivated(true)把它设置为“激活”状态。当视图不再被选择时,它会设制为false。它是通过使用XML StateListDrawables直接开启选择模式从而突出选择模式。
你可以用ViewHolder的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); } }
当然,如果你想用其它方式实现选择,你可以。你潜力无限。尽管,Drawable和state list动画做激活状态是默认的好选择。
如果仅仅是这些,我就不用花费那么多时间了。但是我花费了那么多时间,因为我固执的要实现一些我想要的视觉效果。
Material animations
Material Design包括这种非常酷的波纹动画。如果你在 Implementing Material Design in Your Android app 中读过它,你将发现你能在任何时候使用它,当你使用?android:selectableItemBackground 做为你的背景时。
如果你要使用激活状态,虽然,这不是一个好的选择。?android:selectableItemBackground的可视化不支持激活状态。你可以试着用状态选择drawable(state selector drawable)去实现支持激活状态,但是它最终的结果看起来是这样的:
你每次点击它的时候选择中状态都会有反应。所以,当你点击视图关闭激活状态时,你同样会得到波纹效果。这对我没有意义。在我心里,list只有两种状态:正常状态和选择状态。在正常状态,一个点击能产生?android:selectableItemBackground带给我的效果。在选择状态,一个点击只能触发开启和关闭激活状态,在这当中不应该有波纹效果。在Lollipop中拥有自带的Material Design是非常好的:一个状态动画列表去把选择的item在translationZ中提升。
使用原生Android API实现这样的效果,这样做要比使用状态列表drawable和animator更明智。你需要的视图需要有两种不同的状态:其中一个使用默认的drawable和animator集合,另一个专为选择提供不同的集合(and one in which it uses a different set exclusively for selection)。像这样:
SwappingHolder
这是我写到应用中的第二个工具:一个名叫SwappingHolder的ViewHolder子类,它需要做的工作就像我之前描述的那样。SwappingHolder实现正常的ViewHolder功能并增加了六个属性:
public Drawable getSelectionModeBackgroundDrawable(); public Drawable getDefaultModeBackgroundDrawable(); public StateListAnimator getSelectionModeStateListAnimator(); public StateListAnimator getDefaultModeStateListAnimator(); public boolean isSelectable(); public boolean isActivated();
当你第一次创建它的时候,SwappingHolder将会忽略它的itemView的背景drawable和状态列表
animator,并把这些初始化值存贮在defaultModeBackgroundDrawable和defaultModeStateListAnimator。如果你设置selectable为true,则它将会切换到这两个属性的选择模式。把selectable设置为false,将会重新设置为默认值。那么激活状态呢?它会调用itemView的激活属性。
长话短说,当被选择的item被激活时,SwappingHolder使用selectionModelStateListAnimator把这个item抬高一些。并且,selectionModeBackgroundDrawable使用appcompate Material主题中的colorAccent属性。
所以使用这个。最后一点,为选择逻辑提供一种方便打开关闭的方式钩住一切。
连接选择逻辑
重复一遍,如果你喜欢你可以自己实现。这里需要两步:当绑定crime时更新ViewHolder,并且增加点击事件。绑定crime时更新,并在bindCrime()中添加更多的代码:
private class CrimeHolder extends SwappingHolder { ... public void bindCrime(Crime crime) { mCrime = crime; mSolvedCheckBox.setChecked(crime.isSolved()); setSelectable(mMultiSelector.isSelectable()); setActivated(mMultiSelector.isSelected(getPosition())); } }
所以当你每次把你的ViewHolder绑定到另一个crime时,你需要两次检查来确定:第一,当前是否在选择状态;第二,绑定的item是否被选择了。
然后绑定一个点击监听事件:
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 } } }
对于单选,onClick()的实现要比这个复杂,因为它需要在点击一个时把其它的选项取消。
这并不是完整的代码,但是你需要在用的时候自己实现。我已经在MultiSelector中做一些工作,可以代替样板。
打开关闭一切
最后一步:打开关闭它。你必须为CHOICE_MODE_MULTIPLE_MODAL做这些,当你需要别的选择模式时你同样要去实现。
添加notifyDataSetChanged()是最简单的增强你的setSelectable()的方法:
public void setSelectable(boolean isSelectable) { mIsSelectable = isSelectable; mRecyclerView.getAdapter().notifyDataSetChanged(); }
在ListView(和ViewPager)中当你感得你做错时使用notifyDataSetChanged()往往是最好的解决办法。在RecyclerView中我也推荐你使用同样的方法。
这是原因:使用RecyclerView最大的原因是它能很容易的激活更改列表内容。例如,你想要删除列表中第一个crime,你可以这样做:
// Delete the 0th crime from your model mCrimes.remove(0); // Notify the adapter that it was removed mRecyclerView.getAdapter().notifyItemRemoved(0);
调用notifyDataSetChanged()可以打破这些,因为它能中断那些动画。
RecyclerView中的ItemAnimator将会为你推动这变化。默认的动画会使用item0淡出,然后另一个item进入。
如果你在使用itemAnimator之后立即调用notifyDataSetChanged()会发生什么?它将会杀死所有的即将发生的动画,重新查询适配器并重新展示一切。并且立即见效。通常那是正确的选择,但是注意:如果你可以使用除了notifyDataSetChanged之外的方法更新你的列表,去做!
那么其它的实现方式是怎么样的?像这样:
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); } } }
我们可以遍历所有的ViewHolder,强制转化为SwappingHolder然后告诉它们现在的状态是什么。
像SwappingHolder,MultiSelector己经为你做了。MultiSelector知道哪一个ViewHolder被选择了,所以你所需要做的就是更新你的用户接口:
mMultiSelector.setSelectable(true);
使用上下文操作模式
当实现了setSelecteable(),你可以使用常用的ActionMode.Callback实现其余的CHOICE_MODE_MULTIPLE_MODAL。从相关的回调方法中调用你的setSelectable()。
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) { ... } }
然后通过长按监听打开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; } }
TL;DR:通过一个Library实现Choice Mode
现在实现了MultiSelect。如果你不在乎,你更喜欢选择一种更直接的实现方案。
我注意到一个现成的解决方案: Lucas Rocha实现的library,叫做TwoWayView。我没有足够的时间研究其中的细节,但是我可以告诉你它复制了ListView中的setChoiceMode()方法,还有其它的一些方法。对于那些想用RecyclerView来代替ListVIew的人们来说,TwoWayView是一个非常棒的解决方案。如果你喜欢用,我遵从他们的文档。
当然,这时候我的同事告诉我这个,我已经实现了自己的多选,但那看起来很难。或许你会发现它有用。我会尝试实现一些更小、专注、灵活易用的代码。这并没有很多代码,只有有限的几个明智选择使用“魔法”。这是它如何实现的。
MultiSelector:基础
第一步,引入library。在你的build.gradle中加入下面这一行:
compile 'com.bignerdranch.android:recyclerview-multiselect:+'
第二步,创建一个MultiSelector实例。在我的示例app中,我在Fragment中实现:
public class CrimeListFragment extends Fragment { private MultiSelector mMultiSelector = new MultiSelector(); ... }
MultiSelector知道哪一个item被选择了,它同样是你控制item选择的接口,这个接口访问绑定的一切( and is also your interface for controlling item selection across everything it is hooked up to)。这种情况下,所有的一切都在适配器中。
为MultiSelector连接一个SwappingHolder,在构造函数传入MultiSelector,并且使用点击监听器调用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()模拟点击一个选中的item;如果MultiSelector是在选择模式,它会返回true并且触发该item的选择。如果不是,它将返回false,并且不做任何事情。
打开多选模式,可以调用setSelectable(true):
mMultiSelector.setSelectable(true);
这将会触发MultiSelector上的标志,开启它和它所有的SwappingHolder。这是SwappingHolder为你做的一切——它扩展了MultiSelectorBindingHolder,并把自己绑定到你的MultiSelector上。
对于基本的多选,这就是所有需要做的工作。当你需要知道是否要选择一个item时,问问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); } }
单选
使用单选代替多选,使用SingleSelector代替MultiSelector:
public class CrimeListFragment extends Fragment { private MultiSelector mMultiSelector = new SingleSelector(); ... }
通过长按模式化多选
获得如果CHOICE_MODE_MULTIPLE_MODAL一样的效果,你同样可以向上面描述的那样实现自己的ActionMode.Callback,或者使用提供的抽象实现——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在onPrepareActionMode下将会调用MultiSelector.setSelectable(true)和clearSelections(),在onDestroyActionMode下调用setSelectable(false)。在长按监听器中像其它的action mode那样踢开它。
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为它的itemView提供了两套drawable和状态列表动画:一种是在默认模式下使用,另一种在选择模式下使用。你可以通过调用下面的方法自定义:
public void setSelectionModeBackgroundDrawable(Drawable drawable); public void setDefaultModeBackgroundDrawable(Drawable drawable); public void setSelectionModeStateListAnimator(int resId); public void setDefaultModeStateListAnimator(int resId);
这些状态列表动画设置函数在API 21以下调用也是安全的,并且将返回空操作。
定制关闭标签
如果你需要定制比SwappingHolder提供好的选择状态效果,你可以扩展MultiSelectorBindingHolder抽象类:
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() { ... } }
如果这样提供的相同方法还是太局限,你可以实现SelectableHolder接口代替。它需要更多的代码:你将需要在每次调用mMultiSelector.bindHolder()时绑定你的ViewHolder到MultiSelector当onBindViewHolder被调用的时候。
足够了吗?
这篇文章中我们学习了在RecyclerView中选择item。现在你知道了怎么去显示哪个视图是被选择和未选择的,在列表中跟踪被选择和未被选择的状态,在一个上下文action mode中关闭和打开所有东西