1. 程式人生 > >RecyclerView Part 2:選擇模式

RecyclerView Part 2:選擇模式

原文連結   作者:Bill Phillips    譯者:趙峰

一位非常有名的人曾經說過,

此生的事情永遠比後世還容易。因為,此生自己做主。

這是真的嗎?或許這值的去討論。當去選擇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)去實現支援啟用狀態,但是它最終的結果看起來是這樣的:

le-drawables

你每次點選它的時候選擇中狀態都會有反應。所以,當你點選檢視關閉啟用狀態時,你同樣會得到波紋效果。這對我沒有意義。在我心裡,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)。像這樣:

lection-view

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中關閉和開啟所有東西。