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)去實現支援啟用狀態,但是它最終的結果看起來是這樣的:
你每次點選它的時候選擇中狀態都會有反應。所以,當你點選檢視關閉啟用狀態時,你同樣會得到波紋效果。這對我沒有意義。在我心裡,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中關閉和開啟所有東西。