9.製作List View的節頭部
9.1 問題
需要建立一個有若干節內容的列表,其中每一節的頂部都有各自的頭部。
9.2 解決方案
(API Level 1)
我們可以通過構建自定義列表介面卡來實現此效果,此介面卡利用了對多種檢視型別的支援。介面卡依賴getViewTypeCount()和getItemViewType()來確定將多少種檢視用作列表中的行。在大多數情況下,如果所有的行型別相同,就忽略上述方法。然而,在此可以使用這些方法回撥為頭部行和內容行定義獨特的型別。
9.3 實現機制
下圖顯示了帶有節頭部的示例列表的預覽效果。

分節的列表
我們首先在以下程式碼中定義SectionItem資料結構,用於表示列表中的每一節。此項將儲存節標題以及此標題下列出的資料陣列子集。
包含每一節資料的結構public class SectionItem<T> { private String mTitle; private T[] mItems; public SectionItem(String title, T[] items) { if (title == null) title = ""; mTitle = title; mItems = items; } public String getTitle() { return mTitle; } public T getItem(int position) { return mItems[position]; } public int getCount() { //為節標題包含額外的項 return (mItems == null ? 1 : 1 + mItems.length); } @Override public boolean equals(Object object) { //如果兩個節有相同的標題,則它們相等 if (object != null && object instanceof SectionItem) { return ((SectionItem) object).getTitle().equals(mTitle); } return false; } }
此結構將使列表介面卡中的邏輯更易於管理。在以下程式碼中,我們可以看到提供分節列表檢視的介面卡。此介面卡的任務是將每個節項(包括它們的頭部)的位置對映到介面卡檢視(如ListView)所瞭解的可見列表的全域性位置。
用於顯示多個節的ListAdapter
public abstract class SimpleSectionsAdapter<T> extends BaseAdapter implements AdapterView.OnItemClickListener { /* 為每個檢視型別定義常量 */ private static final int TYPE_HEADER = 0; private static final int TYPE_ITEM = 1; private LayoutInflater mLayoutInflater; private int mHeaderResource; private int mItemResource; /* 所有節的唯一聚合*/ private List<SectionItem<T>> mSections; /* 節的分組,按其初始位置設定鍵 */ private SparseArray<SectionItem<T>> mKeyedSections; public SimpleSectionsAdapter(ListView parent, int headerResId, int itemResId) { mLayoutInflater = LayoutInflater.from(parent.getContext()); mHeaderResource = headerResId; mItemResource = itemResId; //建立包含自動排序鍵的集合 mSections = new ArrayList<SectionItem<T>>(); mKeyedSections = new SparseArray<SectionItem<T>>(); //將自身附加為列表的單擊處理程式 parent.setOnItemClickListener(this); } /* *向列表新增新的帶標題的節, * 或者更新現有的節 */ public void addSection(String title, T[] items) { SectionItem<T> sectionItem = new SectionItem<T>(title, items); //新增節,替換具有相同標題的現有節 int currentIndex = mSections.indexOf(sectionItem); if (currentIndex >= 0) { mSections.remove(sectionItem); mSections.add(currentIndex, sectionItem); } else { mSections.add(sectionItem); } //對最新的集合排序 reorderSections(); //表明檢視資料已改變 notifyDataSetChanged(); } /* * 將帶有初始全域性位置的節標記為可引用的鍵 */ private void reorderSections() { mKeyedSections.clear(); int startPosition = 0; for (SectionItem<T> item : mSections) { mKeyedSections.put(startPosition, item); //此計數包括頭部試圖 startPosition += item.getCount(); } } @Override public int getCount() { int count = 0; for (SectionItem<T> item : mSections) { //新增項的計數 count += item.getCount(); } return count; } @Override public int getViewTypeCount() { //兩種檢視型別:頭部和項 return 2; } @Override public int getItemViewType(int position) { if (isHeaderAtPosition(position)) { return TYPE_HEADER; } else { return TYPE_ITEM; } } @Override public T getItem(int position) { return findSectionItemAtPosition(position); } @Override public long getItemId(int position) { return position; } /* *重寫並返回false,告訴ListView有一些項(頭部)不可點選 */ @Override public boolean areAllItemsEnabled() { return false; } /* * 重寫以告訴 ListView 哪些項(頭部)是不可點選的 */ @Override public boolean isEnabled(int position) { return !isHeaderAtPosition(position); } @Override public View getView(int position, View convertView, ViewGroup parent) { switch (getItemViewType(position)) { case TYPE_HEADER: return getHeaderView(position, convertView, parent); case TYPE_ITEM: return getItemView(position, convertView, parent); default: return convertView; } } private View getHeaderView(int position, View convertView, ViewGroup parent) { if (convertView == null) { convertView = mLayoutInflater.inflate(mHeaderResource, parent, false); } SectionItem<T> item = mKeyedSections.get(position); TextView textView = (TextView) convertView.findViewById(android.R.id.text1); textView.setText(item.getTitle()); return convertView; } private View getItemView(int position, View convertView, ViewGroup parent) { if (convertView == null) { convertView = mLayoutInflater.inflate(mItemResource, parent, false); } T item = findSectionItemAtPosition(position); TextView textView = (TextView) convertView.findViewById(android.R.id.text1); textView.setText(item.toString()); return convertView; } /** OnItemClickListener 方法*/ @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { T item = findSectionItemAtPosition(position); if (item != null) { onSectionItemClick(item); } } /** *重寫方法以處理特定元素的單擊事件,即使用者單擊的@param 項列表項 */ public abstract void onSectionItemClick(T item); /*用於將項對映到節的輔助方法 */ /* * 檢查是否是代表節標題的全域性位置值 */ private boolean isHeaderAtPosition(int position) { for (int i=0; i < mKeyedSections.size(); i++) { //如果此位置是鍵值,則它就是頭部位置 if (position == mKeyedSections.keyAt(i)) { return true; } } return false; } /* * 返回給定全域性位置的顯示列表項 */ private T findSectionItemAtPosition(int position) { int firstIndex, lastIndex; for (int i=0; i < mKeyedSections.size(); i++) { firstIndex = mKeyedSections.keyAt(i); lastIndex = firstIndex + mKeyedSections.valueAt(i).getCount(); if (position >= firstIndex && position < lastIndex) { int sectionPosition = position - firstIndex - 1; return mKeyedSections.valueAt(i).getItem(sectionPosition); } } return null; } }
SimpleSectionsAdapter通過getViewTypeCount返回2,這樣我們就可以分別支援頭部檢視和內容檢視。我們需要為每個檢視提供獨特的型別識別符號,內部列表將這些值用作索引,因此它們應始終從0開始並遞增,從TYPE_HEADER和TYPE_ITEM常量中可以看出這一點。
我們通過addSection()方法將資料按節提供給此介面卡,該方法接受的引數是節標題和此部分的項的陣列。介面卡使每個節標題僅出現一次,因此每次嘗試新增新的節都會刪除具有相同標題的現有節。
新增新的節時,列舉所有的節以確定它們的全域性開始位置是否在列表內。為了在後面進行更快速的訪問,在SparseArray中將這些值儲存為鍵。此集合將用於搜尋給定位置的節。最後,每個節改動需要我們呼叫notifyDataSetChanged(),該方法告訴檢視需要再次查詢介面卡的並重新整理顯示。
通過對每個節的計數求和來確定所有項的計數。這包括頭部檢視,列表將其與其他行同等對待。這在傳統上意味著這些項也是可互動的,會將單擊事件傳送給附加的偵聽器。我們不需要頭部的此行為,因此還必須重寫areAllItemsEnabled()和isEnabled()方法,向視圖表明這些頭部應是不可互動的。
在getView()方法內部,我們傳遞一個檢視型別,該檢視型別用於確定應返回何種型別的檢視。我們知道,對於TYPE_HEADER需要返回設定了標題文字的頭部行。對於TYPE_ITEM,我們返回顯示正確項的內容行。Android框架通過實現getItemViewType()瞭解在每個位置應存在何種檢視型別,該方法將位置與型別識別符號進行關聯。我們建立了簡單的isHeaderAtPosition()方法來做出決定。通過檢查給定位置是否與某個節鍵匹配(這使其成為節中的第一個位置,即頭部),我們可以快速確定型別。
獲得每個項的檢視之後,我們必須確定給定位置的節。另一個輔助方法findSectionItemAtPosition()通過根據前面計算出來的節鍵驗證位置來執行此搜尋。在使用者點選項時,我們也利用此方法將項自身返回給偵聽器,而不是僅返回位置值。在簡單列表中,可能只需要位置就能找到正確的資料,但採用位置對映之後,偵聽器就可以更輕鬆地直接獲得所需的項。
我們已將此介面卡定義為抽象型別,這隻需要應用程式實現為列表項單擊事件提供處理程式。以下兩段程式碼顯示瞭如何將此介面卡與資料繫結並顯示在Activity中。
res/layout/list_header.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="8dp" android:background="#CCF"> <TextView android:id="@android:id/text1" android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>
顯示分節列表的Activity
public class SectionsActivity extends ActionBarActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ListView list = new ListView(this); SimpleSectionsAdapter<String> adapter = new SimpleSectionsAdapter<String>( list, /*資源擴充的上下文 */ R.layout.list_header, /* 頭部檢視的佈局*/ android.R.layout.simple_list_item_1 /* Layout for item views */ ) { //適用於項點選的單擊處理程式 @Override public void onSectionItemClick(String item) { Toast.makeText(SectionsActivity.this, item, Toast.LENGTH_SHORT).show(); } }; adapter.addSection("Fruits", new String[]{"Apples", "Oranges", "Bananas", "Mangos"}); adapter.addSection("Vegetables", new String[]{"Carrots", "Peas", "Broccoli"}); adapter.addSection("Meats", new String[]{"Pork", "Chicken", "Beef", "Lamb"}); list.setAdapter(adapter); setContentView(list); } }
在Activity內部,建立ListView以顯示3個節:水果、蔬菜和肉類。SimpleSectionsAdapter獲取佈局的兩個資源ID,介面卡應該為頭部和內容擴充這些佈局。此例中的頭部佈局是居中顯示單個TextView的自定義佈局,而內容佈局是框架中的標準佈局(android.R.id.simple_list_item_1)。因為介面卡是抽象的,我們需要提供onSectionClick()的定義,在本例中,該定義僅在Toast中顯示所選項的名稱。