組件使用之ExpandableListView
安卓的各種組件中,ListView是一個非常常見的組件,它用於展示列表或者可以放入列表的各種數據;但是ListView也是有它的侷限性的,首先就是要用它實現按照每個列表項展開子表這種需求會非常麻煩,關於位置的控制以及點擊事件的處理都很繁瑣,不巧的是類似的需求非常常見,而且這種展開子表的交互對用戶來說也是十分友好的,iOS有原生控件支持這種需求,安卓自然也不甘示弱地有了ExpandableListView這個組件。
ExpandableListView簡介
ExpandableListView的使用和ListView非常相似,都是利用Adapter來將展示與數據區分開來,而且重用機制也是相同的,不同的地方只在於ExpandableListView要多一個獲取子項的方法。
public class MyAdapter extends BaseExpandableListAdapter {
@Override
public int getGroupCount() {
return 0;
}
@Override
public int getChildrenCount(int groupPosition) {
return 0;
}
@Override
public Object getGroup(int groupPosition) {
return null;
}
@Override
public Object getChild(int groupPosition, int childPosition) {
return null;
}
@Override
public long getGroupId(int groupPosition) {
return 0;
}
@Override
public long getChildId(int groupPosition, int childPosition) {
return 0;
}
@Override
public boolean hasStableIds() {
return false;
}
@Override
public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
return null;
}
@Override
public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
return null;
}
@Override
public boolean isChildSelectable(int groupPosition, int childPosition) {
return false;
}
}
如上所示,這是繼承了BaseExpandableListAdapter後需要實現的接口方法,看起來很複雜繁瑣,但仔細解析就會發現其實和ListView的BaseAdapter異曲同工。
- getGroupCount
- 該方法類似BaseAdapter中的getCount方法,用於返回列表項目數量
- 此處返回組別數量,即可展開項目的數量
- getChildrenCount
- 相對應的,該方法返回指定組別的展開子項數量
- 參數是指定組別的索引
- getGroup
- 該方法類似BaseAdapter中的getItem方法,可用於獲取指定索引的組別數據
- 使用時需要強制類型轉換
- 參數是指定組別的索引
- getChild
- 相對應的,該方法返回指定組別中指定索引的子項數據
- 參數是指定組別的索引以及指定子項的索引
- getGroupId
- 該方法類似BaseAdapter中的getItemId方法,可以獲取指定索引的組別唯一標誌
- 參數是指定組別的索引
- 一般不需要直接使用
- getChildId
- 相對應的,該方法返回子項的唯一標誌
- 參數是指定組別的索引以及指定子項的索引
- hasStableIds
- 該方法返回標誌用於判斷項目ID是否穩定,而如果確定ID穩定(這一般要求獲取項目ID的方法返回唯一ID),則刷新List時會按照ID進行刷新,否則會按照項目顯示的位置進行刷新
- 一般不會直接使用
- getGroupView
- 該方法是主要的項目創建與修改方法,用於返回一個展示組別項目的視圖
- 可使用重用技巧減少開銷
- 參數是指定組別的索引,是否已經展開以及可以重用的視圖
- getChildView
- 該方法是主要的子項創建與修改方法,用於返回一個展示子項的視圖
- 可使用重用技巧減少開銷
- 參數是指定組別的索引,指定子項的索引,是否最後一個子項以及可以重用的視圖
- isChildSelectable
- 該方法指明子項是否可以選擇,可選擇的子項纔可以響應點擊事件
- 一般不會直接使用,而是配合setOnChildClickListener方法爲子項點擊設置回調方法
通常來說ExpandableListView適用於需要二級列表展開的需求上,比如通訊錄,點擊人物展開詳細聯繫方式;又或者商品查詢,點擊商品展開詳細參數等。
使用ExpandableListView的例子如下所示,只要按需建立好數據結構,實現幾個重要的接口方法,便可以成功展示出可展開列表了。
List<GroupItem> dataList;
class GroupItem {
public String titleStr;
public Integer iconRes;
List<ChildItem> childItemList;
}
class ChildItem {
public String content_1;
public String content_2;
}
public class MyAdapter extends BaseExpandableListAdapter {
@Override
public int getGroupCount() {
return dataList == null?0:dataList.size();
}
@Override
public int getChildrenCount(int groupPosition) {
return dataList.get(groupPosition).childItemList == null?0:dataList.get(groupPosition).childItemList.size();
}
@Override
public Object getGroup(int groupPosition) {
return dataList.get(groupPosition);
}
@Override
public Object getChild(int groupPosition, int childPosition) {
return dataList.get(groupPosition).childItemList.get(childPosition);
}
@Override
public long getGroupId(int groupPosition) {
return groupPosition;
}
@Override
public long getChildId(int groupPosition, int childPosition) {
return childPosition;
}
@Override
public boolean hasStableIds() {
return false;
}
@Override
public View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent) {
GroupHolder holder;
if(convertView == null) {
holder = new GroupHolder();
convertView = LayoutInflater.from(resContext).inflate(R.layout.layout_exlv_group, null);
holder.tvTitle = (TextView) convertView.findViewById(R.id.tvTitle);
holder.ivIcon = (ImageView) convertView.findViewById(R.id.ivIcon);
convertView.setTag(holder);
convertView.setOnTouchListener(listTouchListener);
} else {
holder = (GroupHolder) convertView.getTag();
}
GroupItem item = (GroupItem) getGroup(groupPosition);
if(item != null) {
holder.tvTitle.setText(item.titleStr);
holder.ivIcon.setImageResource(item.iconRes);
}
return convertView;
}
@Override
public View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent) {
ChildHolder holder;
if(convertView == null) {
holder = new ChildHolder();
convertView = LayoutInflater.from(resContext).inflate(R.layout.layout_exlv_child, null);
holder.tvContent_1 = (TextView) convertView.findViewById(R.id.tvContent_1);
holder.tvContent_2 = (TextView) convertView.findViewById(R.id.tvContent_2);
convertView.setTag(holder);
} else {
holder = (ChildHolder) convertView.getTag();
}
ChildItem item = (ChildItem) getChild(groupPosition, childPosition);
if(item != null) {
holder.tvContent_1.setText(item.content_1);
holder.tvContent_2.setText((item.content_2));
}
return convertView;
}
@Override
public boolean isChildSelectable(int groupPosition, int childPosition) {
return false;
}
class GroupHolder {
TextView tvTitle;
ImageView ivIcon;
}
class ChildHolder {
TextView tvContent_1;
TextView tvContent_2;
}
}
對於ExpandableListView所使用的數據結構不必侷限於自定義對象的列表嵌套,可以按需通過Map,List接口等實現自己想要使用的數據結構。
進一步使用ExpandableListView
看起來ExpandableListView似乎只是單純地拓展了ListView的功能,讓列表項可以展開以展示更多層級的數據,實踐中類似的需求都可以退化爲多個頁面的列表展示,甚至單個頁面的列表數據切換,那麼使用ExpandableListView的優勢究竟在什麼地方?
答案是顯而易見的,數據的聚合會減少頁面切換或者數據切換的開銷以及用戶因獲取信息的不連貫性而產生的消極情緒,簡潔的數據和複雜的數據通過“展開與收起”這樣的交互方式得以切換展示對用戶的直觀感受具有正面積極的作用。
舉個很簡單的例子,一個簡單的新聞列表,如果使用ListView實現,那麼只能做成點擊標題進入新頁面顯示內容,如果需求中不存在諸如評論等複雜功能的話,這樣的交互會顯得單調而且單獨頁面的信息聚合度不足。
這時候使用ExpandableListView改造頁面就能讓用戶體驗變得更好,點擊標題後並不會跳轉新頁面而是就地展開,顯示內容。
當然這個例子並不實際,現實中的新聞客戶端都有非常複雜的信息聚合,功能也繁多,並不需要ExpandableListView這樣的“小伎倆”,但這個小例子能提供一種思考的方式。
在什麼場景下需要可展開的列表,這不是一個有標準答案的問題,根據需求,用戶羣,展示效果等綜合判斷最後才能確定;但有一點是不會改變的,可展開列表對於增加頁面信息聚合度和提升界面交互性有較強的正面作用,抓準這一點總沒錯。
下面講到的一個例子是作者實際參與的一個生產項目,已經上線了不短的時間,因爲涉及商業項目所以不提具體的東西。
需求大概如此:
- 一個查詢體育比賽的資料庫頁面
- 資料庫首頁有數個標籤,包括熱門比賽以及大洲分類
- 每個大洲有分洲際賽事和國家賽事
- 國家頁面展示國家,點擊國家後展開顯示當前國家的比賽
- 點擊比賽後進入比賽詳情頁面
設計框圖大概如下
標籤欄用於切換不同的大洲以及熱門和國際頁面,一級網格每行四個格子,主要顯示熱門賽事,洲際賽事,每個大洲的主要國家以及世界級賽事;二級網格則用於顯示每個國家自己的賽事。
看起來並不是很難,後臺準備好數據,應用從接口獲取,然後展示出來就行了,沒有實時性需求也沒有安全性需求,除了需要展示的數據可能會比較多之外沒什麼別的特點。
在這個頁面需求中,最需要關注的就是首頁設計,按照設計圖紙,要求首頁以網格形式展示賽事和國家的圖標,用按鈕切換,總計六個標籤頁,分別展示熱門賽事,四個大洲以及國際賽事。
六個標籤頁顯然是使用Fragment+ViewPager實現,但標籤頁內的網格型展示如何實現呢?首先想到的是GridView,它和ListView非常相似,只需要繼承BaseAdapter實現相關方法即可。
但是在這個情況下,不可以使用GridView來實現網格展示,因爲需求中有點擊展開的部分,GridView只能用來實現不存在展開的賽事列表展示,而需要展開的國家列表靠它無法解決問題。
作者曾經嘗試過使用屬性動畫手動控制GridView的每個Cell來強行實現展開,最後還是放棄了,不但難度很高,效果細節上也讓人無法接受。
自定義Layout是一個可能的解決之道,但自定義的話需要花費不少時間,而且可能出現的BUG也不少,對於一個有Deadline的商業項目而言應該儘量避免使用自定義Layout的情況,能用原生的組件是最好的。
思來想去最後找到了ExpandableListView,從某種角度來說這是一次逆向思維,不再去考慮網格型展示界面怎麼實現展開,而是反過來考慮可以展開的界面如何實現網格型展示,這樣一想整個需求的難度下降了很多。
思路很簡單,使用ExpandableListView來模擬網格展示,因爲設計圖規定了父級網格每行四個元素,子級網格每行五個元素,那麼使用ExpandableListView的話只需要準備一個擁有一行四個方格的父級Layout和一個一行五個方格的子級Layout,邏輯控制交由Adapter進行,在已知行數和列數的前提下用數學方法確定被點擊的是哪個方格,對於多出來的方格則使用visibility=invisible屬性來實現隱藏。
最後這個需求問題就這樣解決了,而且工作的很好,雖然調試過程中因爲計算點擊方格位置的數學過程不完善導致過BUG,但那只是支末細節罷了,整體功能毫無問題,直到今天線上項目依然使用的這套機制。
通過這個例子,作者只是想說明一點,ExpandableListView表面上看起來只是個高級的ListView,但它能使用的場景多種多樣,並不是說在需要展開的地方就一定要使用ExpandableListView,也不是說看起來不像列表的地方就用不着ExpandableListView,這些組件的使用可以是非常靈活的,並不需要遵循什麼準則,只要能成功實現功能並且通過測試,如何使用組件並不重要。
ExpandableListView使用小技巧
ExpandableListView的使用有不少技巧,作者所知僅有皮毛,在此分享。
- ExpandableListView的分組點擊回調
默認情況下ExpandableListView的每個分組點擊時都會展開其子級,根據Adapter中提供的getChildView方法來展示子級數據。但是在某些時候這個默認邏輯並不適用,有的需求中需要父級滿足一定條件時才展開,否則不予展開;有的需求要進入某種特定狀態後才能展開等等。這個時候就需要重新自定義分組點擊回調方法了。
方法非常簡單,只需要重設onGroupClickListener即可,在自定義的Listener中按照需求處理。同時,ExpandableListView還允許用戶自行設置onGroupCollapseListener監聽以及onGroupExpanListener監聽,這樣能適應更加細緻和複雜的需求。
exlvMain.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() {
@Override
public boolean onGroupClick(ExpandableListView parent, View v, int groupPosition, long id) {
// 可以隨意根據需求自定義行爲,注意返回值爲false表示不展開子項,爲true纔是展開
return false;
}
});
- ExpandableListView的分組標題浮動
一般來說ExpandableListView和ListView沒有太大的區別,但既然ExpandableListView有了分組,那麼每一組的標題欄就會有不同,以之前說過的新聞頁面爲例,在那種情況下標題是新聞標題;如果換成之前說的資料庫頁面,標題就是四個方格。
考慮這樣一種需求,一個備忘錄,備忘按照日期分組,每個組的備忘分別展示,要求展示到一張表上。進入頁面時全部日期標題展開,可以點擊收起,滑動列表時日期標題常駐頂部。
這個需求聽起來感覺熟悉,事實上很多線上項目就有類似的實現,列表滑動時總有個表頭一樣的東西浮動在頂端,還會根據滑動到的位置發生變化。有這種浮動標題欄當然就會比什麼都沒有的列表要對用戶友好得多,畢竟當列表龐大的時候沒有人喜歡往回翻老長一段去看錶頭或者標題是什麼。
要實現這個需求,乍一看覺得不容易,但實際上ExpandableListView提供了一些可以幫助實現的方法,下面就分析一下如何實現。
首先需要一個在列表滑動時展示的標題欄,要和列表中的標題欄完全相同,和列表本身放在同一個FrameLayout下,理好順序讓標題欄蓋住列表。
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ExpandableListView
android:id="@+id/exlvMain"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</ExpandableListView>
<LinearLayout
android:id="@+id/vGroupIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tvTitleIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
</LinearLayout>
</FrameLayout>
大概如上所示,ID爲vGroupIndicator的部分就是蓋在列表上的標題欄。
隨後自定義ExpandableListView,只需要簡單地繼承ExpandableListView然後重寫和添加一些方法即可。
public class GroupIndicatorExpandableListView
extends ExpandableListView
implements AbsListView.OnScrollListener {
public GroupIndicatorExpandableListView(Context context) {
super(context);
}
public GroupIndicatorExpandableListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public GroupIndicatorExpandableListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
//
}
// 該接口回調方法就是用於實現浮動標題欄的
private OnGroupIndicatorShowListener onGroupIndicatorShowListener;
public void setOnGroupIndicatorShowListener(OnGroupIndicatorShowListener onGroupIndicatorShowListener) {
setOnScrollListener(this);
this.onGroupIndicatorShowListener = onGroupIndicatorShowListener;
}
// 該方法的重載實現是重點
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
int totalItemCount) {
boolean show;
long listPosition = getExpandableListPosition(firstVisibleItem);
// 當前第一行歸屬的組ID getPackedPositionGroup 返回所選擇的組
int groupId = getPackedPositionGroup(listPosition);
// 當前第一行的子視圖類型
int viewType = getPackedPositionType(listPosition);
// 當前第二行的子視圖類型
int nextViewType = getPackedPositionType(getExpandableListPosition(
firstVisibleItem + 1));
if((viewType == PACKED_POSITION_TYPE_NULL
&& nextViewType == PACKED_POSITION_TYPE_NULL)
||(viewType == PACKED_POSITION_TYPE_NULL
&& nextViewType == PACKED_POSITION_TYPE_GROUP)) {
show = false;
} else if(viewType == PACKED_POSITION_TYPE_CHILD
&& nextViewType == PACKED_POSITION_TYPE_GROUP) {
show = false;
} else {
show = true;
}
if(onGroupIndicatorShowListener != null) {
onGroupIndicatorShowListener.OnGroupIndicatorShow(!canPullDown() && show, groupId);
}
}
private boolean canPullDown() {
return getCount() == 0
||getFirstVisiblePosition() == 0
&& getChildAt(0).getTop() >= 0;
}
public interface OnGroupIndicatorShowListener {
void OnGroupIndicatorShow(boolean show, int groupId);
}
}
通過在onScroll方法中計算當前第一個顯示的是何種項目來決定是否需要顯示標題欄,只要按需編寫onGroupIndicatorShowListener的實現類即可。
indicator = findViewById(R.id.vGroupIndicator);
tvIndicator = (TextView) findViewById(R.id.tvTitleIndicator);
exlvMain = (GroupIndicatorExpandableListView) findViewById(R.id.exlvMain);
exlvMain.setOnGroupIndicatorShowListener(new GroupIndicatorExpandableListView
.OnGroupIndicatorShowListener() {
@Override
public void OnGroupIndicatorShow(boolean show, final int groupId) {
if(groupId >= 0) {
updateIndicator(groupId);
} else {
show = false;
}
indicator.setVisibility(show?View.VISIBLE:View.GONE);
indicator.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
if(exlvMain.isGroupExpanded(groupId)) {
exlvMain.collapseGroup(groupId);
} else {
exlvMain.expandGroup(groupId);
}
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
});
其中的輔助方法updateIndicator用於更新浮動標題欄上顯示的文字或圖標等信息
private void updateIndicator(int groupPosiion) {
if(dataList != null && groupPosiion < dataList.size()) {
GroupItem grp = dataList.get(groupPosiion);
tvIndicator.setText(grp.titleStr);
}
}
至此這個浮動標題欄就可以使用了。
ExpandableListView作爲安卓系統的一種重要組件,其使用方式靈活多變,而且根據它拓展出來的第三方開源庫也有不少,有些加強了動畫效果,還有些拓展了功能,這些對於實際生產環境中的應用開發是有很大幫助的。