原文链接 作者:Bill Phillips 译者:赵峰
不要错过第一部分,地址是:为ListView专家写的基础
一位非常有名的人曾经说过,
此生的事情永远比后世还容易。因为,此生自己做主。
这是真的吗?或许这值的去讨论。当去选择RecyclerView中的item时,虽然你实际上是操作自己:RecyclerView并没有给你相关的工具去做这件事 。所以,我们应该怎么去实现它?
我想说如果你按我的方法做会很简单,现在开始。下面是我研究发现的。
(如果你喜欢,你可以看完整的项目,在这里GitHub repo。如果你只想很快的去使用它,可以跳过前面的部分,直接阅读后面的“TL;DR”)
回顾:选择模式和上下文操作模式(Chocie Modes和Contextual Action Modes)
我打算实现像Android Programming书中CriminalIntent应用中的多项选择那样的效果:通过一个上下文操作模式。下面就是它的代码实现(为了方便展示,我只展示有趣的部分——当然你可以在这里找到所有的代码):
01 | listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE_MODAL); |
03 | listView.setMultiChoiceModeListener( new MultiChoiceModeListener() { |
04 | public boolean onCreateActionMode(ActionMode mode, Menu menu) { ... } |
05 | public void onItemCheckedStateChanged(ActionMode mode, int position, |
06 | long id, boolean checked) { ... } |
07 | public boolean onActionItemClicked(ActionMode mode, MenuItem item) { |
08 | switch (item.getItemId()) { |
09 | case R.id.menu_item_delete_crime: |
10 | CrimeAdapter adapter = (CrimeAdapter)getListAdapter(); |
11 | CrimeLab crimeLab = CrimeLab.get(getActivity()); |
12 | for ( int i = adapter.getCount() - 1 ; i >= 0 ; i--) { |
13 | if (getListView().isItemChecked(i)) { |
14 | crimeLab.deleteCrime(adapter.getItem(i)); |
18 | adapter.notifyDataSetChanged(); |
23 | public boolean onPrepareActionMode(ActionMode mode, Menu menu) { ... } |
24 | 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,它是这样实现的:
2 | mListView.setItemChecked( 0 , true ); |
5 | mListView.isItemChecked( 0 ); |
8 | mListView.getChoiceMode(); |
我们自己的实现是这样子的:
01 | private SparseBooleanArray mSelectedPositions = new SparseBooleanArray(); |
02 | private mIsSelectable = false ; |
04 | private void setItemChecked( int position, boolean isChecked) { |
05 | mSelectedPositions.put(position, isChecked); |
08 | private boolean isItemChecked( int position) { |
09 | return mSelectedPositions.get(position); |
12 | private void setSelectable( boolean selectable) { |
13 | mIsSelectable = selectable; |
16 | private boolean isSelectable() { |
现在程序不会像ListView.setItemChecked()那样更新用户接口,但它现在将会那样做。
当然,你可以用自己喜欢的方式去追踪。对象集合是一个不错的选择。
我把这个想法放到一个叫做MultiSelector的对象中:
1 | MultiSelector selector = new MultiSelector(); |
2 | selector.setSelected( 0 , true ); |
3 | selector.isSelected( 0 ); |
4 | selector.setSelectable( true ); |
5 | selector.isSelectable(); |
显示选项状态
ListView从Honeycomb开始,item选择就已经像这样可视化了:当一个item被选中时,视图就会通过调用setActivated(true)把它设置为“激活”状态。当视图不再被选择时,它会设制为false。它是通过使用XML StateListDrawables直接开启选择模式从而突出选择模式。
你可以用ViewHolder的bindCrime做同样的事:
01 | private class CrimeHolder extends ViewHolder { |
03 | public void bindCrime(Crime crime) { |
05 | mSolvedCheckBox.setChecked(crime.isSolved()); |
07 | boolean isSelected = mMultiSelector.isSelected(getPosition()); |
08 | 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功能并增加了六个属性:
1 | public Drawable getSelectionModeBackgroundDrawable(); |
2 | public Drawable getDefaultModeBackgroundDrawable(); |
4 | public StateListAnimator getSelectionModeStateListAnimator(); |
5 | public StateListAnimator getDefaultModeStateListAnimator(); |
7 | public boolean isSelectable(); |
8 | 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()中添加更多的代码:
01 | private class CrimeHolder extends SwappingHolder { |
04 | public void bindCrime(Crime crime) { |
06 | mSolvedCheckBox.setChecked(crime.isSolved()); |
08 | setSelectable(mMultiSelector.isSelectable()); |
09 | setActivated(mMultiSelector.isSelected(getPosition())); |
所以当你每次把你的ViewHolder绑定到另一个crime时,你需要两次检查来确定:第一,当前是否在选择状态;第二,绑定的item是否被选择了。
然后绑定一个点击监听事件:
01 | private class CrimeHolder extends SwappingHolder |
02 | implements View.OnClickListener { |
05 | public CrimeHolder(View itemView) { |
08 | mSolvedCheckBox = (CheckBox) itemView |
09 | .findViewById(R.id.crime_list_item_solvedCheckBox); |
10 | itemView.setOnClickListener( this ); |
14 | public void onClick(View view) { |
15 | if (mMultiSelector.isSelectable()) { |
17 | setActivated(!isActivated()); |
18 | mMultiSelector.setSelected(getPosition(), isActivated()); |
对于单选,onClick()的实现要比这个复杂,因为它需要在点击一个时把其它的选项取消。
这并不是完整的代码,但是你需要在用的时候自己实现。我已经在MultiSelector中做一些工作,可以代替样板。
打开关闭一切
最后一步:打开关闭它。你必须为CHOICE_MODE_MULTIPLE_MODAL做这些,当你需要别的选择模式时你同样要去实现。
添加notifyDataSetChanged()是最简单的增强你的setSelectable()的方法:
1 | public void setSelectable( boolean isSelectable) { |
2 | mIsSelectable = isSelectable; |
3 | mRecyclerView.getAdapter().notifyDataSetChanged(); |
在ListView(和ViewPager)中当你感得你做错时使用notifyDataSetChanged()往往是最好的解决办法。在RecyclerView中我也推荐你使用同样的方法。
这是原因:使用RecyclerView最大的原因是它能很容易的激活更改列表内容。例如,你想要删除列表中第一个crime,你可以这样做:
4 | mRecyclerView.getAdapter().notifyItemRemoved( 0 ); |
调用notifyDataSetChanged()可以打破这些,因为它能中断那些动画。
RecyclerView中的ItemAnimator将会为你推动这变化。默认的动画会使用item0淡出,然后另一个item进入。
如果你在使用itemAnimator之后立即调用notifyDataSetChanged()会发生什么?它将会杀死所有的即将发生的动画,重新查询适配器并重新展示一切。并且立即见效。通常那是正确的选择,但是注意:如果你可以使用除了notifyDataSetChanged之外的方法更新你的列表,去做!
那么其它的实现方式是怎么样的?像这样:
1 | public void setSelectable( boolean isSelectable) { |
2 | mIsSelectable = isSelectable; |
3 | for ( int i = 0 ; i < mRecyclerView.getAdapter().getItemCount(); i++) { |
4 | RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForPosition(i); |
6 | ((SwappingHolder)holder).setSelectable(isSelectable); |
我们可以遍历所有的ViewHolder,强制转化为SwappingHolder然后告诉它们现在的状态是什么。
像SwappingHolder,MultiSelector己经为你做了。MultiSelector知道哪一个ViewHolder被选择了,所以你所需要做的就是更新你的用户接口:
1 | mMultiSelector.setSelectable( true ); |
使用上下文操作模式
当实现了setSelecteable(),你可以使用常用的ActionMode.Callback实现其余的CHOICE_MODE_MULTIPLE_MODAL。从相关的回调方法中调用你的setSelectable()。
01 | private ActionMode.Callback mDeleteMode = new ActionMode.Callback() { |
03 | public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { |
09 | public void onDestroyActionMode(ActionMode actionMode) { |
14 | public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { ... } |
17 | public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { ... } |
然后通过长按监听打开action mode:
01 | private class CrimeHolder extends SwappingHolder |
02 | implements View.OnClickListener, View.OnLongClickListener { |
06 | public CrimeHolder(View itemView) { |
10 | itemView.setOnClickListener( this ); |
11 | itemView.setOnLongClickListener( this ); |
12 | itemView.setLongClickable( true ); |
16 | public boolean onLongClick(View v) { |
18 | ActionBarActivity activity = (ActionBarActivity)getActivity(); |
19 | activity.startSupportActionMode(deleteMode); |
20 | setSelected( this , true ); |
TL;DR:通过一个Library实现Choice Mode
现在实现了MultiSelect。如果你不在乎,你更喜欢选择一种更直接的实现方案。
我注意到一个现成的解决方案: Lucas Rocha实现的library,叫做TwoWayView。我没有足够的时间研究其中的细节,但是我可以告诉你它复制了ListView中的setChoiceMode()方法,还有其它的一些方法。对于那些想用RecyclerView来代替ListVIew的人们来说,TwoWayView是一个非常棒的解决方案。如果你喜欢用,我遵从他们的文档。
当然,这时候我的同事告诉我这个,我已经实现了自己的多选,但那看起来很难。或许你会发现它有用。我会尝试实现一些更小、专注、灵活易用的代码。这并没有很多代码,只有有限的几个明智选择使用“魔法”。这是它如何实现的。
MultiSelector:基础
第一步,引入library。在你的build.gradle中加入下面这一行:
1 | compile 'com.bignerdranch.android:recyclerview-multiselect:+' |
(你可以在GitHub上找到工程,和它的Javadocs)
第二步,创建一个MultiSelector实例。在我的示例app中,我在Fragment中实现:
1 | public class CrimeListFragment extends Fragment { |
2 | 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():
01 | private class CrimeHolder extends SwappingHolder |
02 | implements View.OnClickListener, View.OnLongClickListener { |
03 | private final CheckBox mSolvedCheckBox; |
06 | public CrimeHolder(View itemView) { |
07 | super (itemView, mMultiSelector); |
09 | mSolvedCheckBox = (CheckBox) itemView.findViewById(R.id.crime_list_item_solvedCheckBox); |
10 | itemView.setOnClickListener( this ); |
14 | public void onClick(View v) { |
18 | if (!mMultiSelector.tapSelection( this )) { |
20 | Intent i = new Intent(getActivity(), CrimePagerActivity. class ); |
21 | i.putExtra(CrimeFragment.EXTRA_CRIME_ID, c.getId()); |
MultiSelector.tapSelection()模拟点击一个选中的item;如果MultiSelector是在选择模式,它会返回true并且触发该item的选择。如果不是,它将返回false,并且不做任何事情。
打开多选模式,可以调用setSelectable(true):
1 | mMultiSelector.setSelectable( true ); |
这将会触发MultiSelector上的标志,开启它和它所有的SwappingHolder。这是SwappingHolder为你做的一切——它扩展了MultiSelectorBindingHolder,并把自己绑定到你的MultiSelector上。
对于基本的多选,这就是所有需要做的工作。当你需要知道是否要选择一个item时,问问multiselector:
1 | for ( int i = mCrimes.size(); i > 0 ; i--) { |
2 | if (mMultiSelector.isSelected(i, 0 )) { |
3 | Crime crime = mCrimes.get(i); |
4 | CrimeLab.get(getActivity()).deleteCrime(crime); |
5 | mRecyclerView.getAdapter().notifyItemRemoved(i); |
单选
使用单选代替多选,使用SingleSelector代替MultiSelector:
1 | public class CrimeListFragment extends Fragment { |
2 | private MultiSelector mMultiSelector = new SingleSelector(); |
通过长按模式化多选
获得如果CHOICE_MODE_MULTIPLE_MODAL一样的效果,你同样可以向上面描述的那样实现自己的ActionMode.Callback,或者使用提供的抽象实现——ModalMultiSelectorCallback:
01 | private ActionMode.Callback mDeleteMode = new ModalMultiSelectorCallback(mMultiSelector) { |
03 | public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { |
04 | getActivity().getMenuInflater().inflate(R.menu.crime_list_item_context, menu); |
09 | public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { |
10 | switch (menuItem.getItemId()) { |
11 | case R.id.menu_item_delete_crime: |
14 | mMultiSelector.clearSelections(); |
ModalMultiSelectorCallback在onPrepareActionMode下将会调用MultiSelector.setSelectable(true)和clearSelections(),在onDestroyActionMode下调用setSelectable(false)。在长按监听器中像其它的action mode那样踢开它。
01 | private class CrimeHolder extends SwappingHolder |
02 | implements View.OnClickListener, View.OnLongClickListener { |
04 | public CrimeHolder(View itemView) { |
08 | itemView.setOnLongClickListener( this ); |
10 | itemView.setLongClickable( true ); |
14 | public boolean onLongClick(View v) { |
16 | ActionBarActivity activity = (ActionBarActivity)getActivity(); |
18 | activity.startSupportActionMode(mDeleteMode); |
19 | mMultiSelector.setSelected( this , true ); |
自定义选择视觉效果
SwappingDrawable为它的itemView提供了两套drawable和状态列表动画:一种是在默认模式下使用,另一种在选择模式下使用。你可以通过调用下面的方法自定义:
1 | public void setSelectionModeBackgroundDrawable(Drawable drawable); |
2 | public void setDefaultModeBackgroundDrawable(Drawable drawable); |
3 | public void setSelectionModeStateListAnimator( int resId); |
4 | public void setDefaultModeStateListAnimator( int resId); |
这些状态列表动画设置函数在API 21以下调用也是安全的,并且将返回空操作。
定制关闭标签
如果你需要定制比SwappingHolder提供好的选择状态效果,你可以扩展MultiSelectorBindingHolder抽象类:
01 | public class MyCustomHolder extends MultiSelectorBindingHolder { |
03 | public void setSelectable( boolean selectable) { ... } |
06 | public boolean isSelectable() { ... } |
09 | public void setActivated( boolean activated) { ... } |
12 | public boolean isActivated() { ... } |
如果这样提供的相同方法还是太局限,你可以实现SelectableHolder接口代替。它需要更多的代码:你将需要在每次调用mMultiSelector.bindHolder()时绑定你的ViewHolder到MultiSelector当onBindViewHolder被调用的时候。
足够了吗?
这篇文章中我们学习了在RecyclerView中选择item。现在你知道了怎么去显示哪个视图是被选择和未选择的,在列表中跟踪被选择和未被选择的状态,在一个上下文action mode中关闭和打开所有东西。