標籤:而不是 center 成員變數 each amount enter 做了 img 命名
項目裡要加一個點擊可收縮展開的列表,要求帶懸停標題,具體效果如:
也就是說,在某一個分組內部滾動時,要求分組標題懸停,當滾出該分組範圍時,把標題頂出去,懸停下一個分組的標題。正好看到一個比較有趣的思路,做了一個實現,在這裡分享一下。代碼結構如下,基本上是一個MVC的架構:
既然是點擊可收縮展開的列表,顯然要用ExpandableListView,關於這個類的用法這裡就不贅述了,網上一搜一大把,其實跟ListView的用法差不多,不過它幫你分了組,所以原來Adapter裡的getView()就變成了getGroupView()和getChildView(),getCount()就變成了getGroupCount()等等。另外既然要支援收縮展開,必然會提供collapseGroup()和expandGroup()等介面。
下面分析如何添加懸停標題,其實精華部分就一句話:懸停標題是畫上去的,而不是加到view hierarchy裡去,具體根據滾動的情況確定如何畫。
首先我們來寫一個DockingExapandableListView類,繼承自ExpandableListView,包含一個View類型的成員變數mDockingHeader。
一、重寫onMeasure()和onLayout()方法
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (mDockingHeader != null) { measureChild(mDockingHeader, widthMeasureSpec, heightMeasureSpec); mDockingHeaderWidth = mDockingHeader.getMeasuredWidth(); mDockingHeaderHeight = mDockingHeader.getMeasuredHeight(); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); if (mDockingHeader != null) { mDockingHeader.layout(0, 0, mDockingHeaderWidth, mDockingHeaderHeight); } }這個比較簡單,就是測量一下這個標題視圖的寬度和高度。
二、重寫dispatchDraw()方法
上面提到,懸停標題是畫上去的,而不是加到view hierarchy裡去的。因此,需要在完成其他子view的繪製之後,再把懸停標題列畫上去:
@Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (mDockingHeaderVisible) { // draw header view instead of adding into view hierarchy drawChild(canvas, mDockingHeader, getDrawingTime()); } }
三、根據滾動狀態決定如何繪製懸停標題
滾動到不同位置,懸停標題的顯示是不同的,因此需要根據滾動狀態定義一個狀態機器的切換。讓DockingExpandableListView實現OnScrollListener介面,並重寫onScroll()方法:
@Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { long packedPosition = getExpandableListPosition(firstVisibleItem); int groupPosition = getPackedPositionGroup(packedPosition); int childPosition = getPackedPositionChild(packedPosition); // update header view based on first visible item // IMPORTANT: refer to getPackedPositionChild(): // If this group does not contain a child, returns -1. Need to handle this case in controller. updateDockingHeader(groupPosition, childPosition); }這裡有幾個比較有意思的方法,都是ExpandableListView內建的API:
getExpandableListPosition():這個API獲得一個所謂的packed position,是一個64位的值,高32位表示group的ID,低32位表示在這個group內部的child ID。
getPackedPositionGroup():擷取group ID,也就是高32位
getPackedPositionChild():擷取child ID,也就是低32位
注意我們給getExpandableListPosition()傳的參數是firstVisibleItem,因此我們就得到了最上方的第一個可見項所屬的group以及組內位置。接下來就是最為關鍵的updateDockingHeader()方法,根據狀態機器來確定如何繪製懸停標題。在看這個方法之前,我們先看一下有哪幾種狀態,定義在IDockingController裡:
public interface IDockingController { int DOCKING_HEADER_HIDDEN = 1; int DOCKING_HEADER_DOCKING = 2; int DOCKING_HEADER_DOCKED = 3; int getDockingState(int firstVisibleGroup, int firstVisibleChild);}一共3種狀態,這些狀態都是什麼含義呢?參見:
DOCKING_HEADER_HIDDEN:當分組沒有展開,或者組裡沒有子項的時候,是不需要繪製懸停標題的
DOCKING_HEADER_DOCKING:當滾動到上一個分組的最後一個子項時,需要把舊的標題“推”出去,“停靠”新的標題,所以這個狀態命名為“docking”
DOCKING_HEADER_DOCKED:新標題“停靠”完畢,在該分組內部滾動,稱為“docked”狀態
基於這個狀態機器,我們來看一下updateDockingHeader()方法的實現:
private void updateDockingHeader(int groupPosition, int childPosition) { if (getExpandableListAdapter() == null) { return; } if (getExpandableListAdapter() instanceof IDockingController) { IDockingController dockingController = (IDockingController)getExpandableListAdapter(); mDockingHeaderState = dockingController.getDockingState(groupPosition, childPosition); switch (mDockingHeaderState) { case IDockingController.DOCKING_HEADER_HIDDEN: mDockingHeaderVisible = false; break; case IDockingController.DOCKING_HEADER_DOCKED: if (mListener != null) { mListener.onUpdate(mDockingHeader, groupPosition, isGroupExpanded(groupPosition)); } // Header view might be "GONE" status at the beginning, so we might not be able // to get its width and height during initial measure procedure. // Do manual measure and layout operations here. mDockingHeader.measure( MeasureSpec.makeMeasureSpec(mDockingHeaderWidth, MeasureSpec.AT_MOST), MeasureSpec.makeMeasureSpec(mDockingHeaderHeight, MeasureSpec.AT_MOST)); mDockingHeader.layout(0, 0, mDockingHeaderWidth, mDockingHeaderHeight); mDockingHeaderVisible = true; break; case IDockingController.DOCKING_HEADER_DOCKING: if (mListener != null) { mListener.onUpdate(mDockingHeader, groupPosition, isGroupExpanded(groupPosition)); } View firstVisibleView = getChildAt(0); int yOffset; if (firstVisibleView.getBottom() < mDockingHeaderHeight) { yOffset = firstVisibleView.getBottom() - mDockingHeaderHeight; } else { yOffset = 0; } // The yOffset is always non-positive. When a new header view is "docking", // previous header view need to be "scrolled over". Thus we need to draw the // old header view based on last child‘s scroll amount. mDockingHeader.measure( MeasureSpec.makeMeasureSpec(mDockingHeaderWidth, MeasureSpec.AT_MOST), MeasureSpec.makeMeasureSpec(mDockingHeaderHeight, MeasureSpec.AT_MOST)); mDockingHeader.layout(0, yOffset, mDockingHeaderWidth, mDockingHeaderHeight + yOffset); mDockingHeaderVisible = true; break; } } }其中,是否顯示懸停標題是通過一個叫做mDockingHeaderVisible的boolean變數控制的,這個在上面的dispatchDraw()方法裡也見到了。
重點看“docking”狀態的處理:通過計算第一個可見項的bottom和高度之間的差異,也就是這個yOffset,確定懸停標題在y軸方向的位移量。這樣在繪製懸停標題的時候,我們就只能看到一部分,造成一種被“推出去”的感覺。
四、懸停標題狀態機器
在剛剛提到的那個IDockingController介面裡有一個方法叫getDockingState(),在updateDockingHeader()方法裡就是通過調用這個方法來確定當前懸停標題的狀態的。DockingExpandableListViewAdapter實現了該介面和方法,完成狀態機器狀態轉換:
@Override public int getDockingState(int firstVisibleGroup, int firstVisibleChild) { // No need to draw header view if this group does not contain any child & also not expanded. if (firstVisibleChild == -1 && !mListView.isGroupExpanded(firstVisibleGroup)) { return DOCKING_HEADER_HIDDEN; } // Reaching current group‘s last child, preparing for docking next group header. if (firstVisibleChild == getChildrenCount(firstVisibleGroup) - 1) { return IDockingController.DOCKING_HEADER_DOCKING; } // Scrolling inside current group, header view is docked. return IDockingController.DOCKING_HEADER_DOCKED; }邏輯非常簡單清晰:
如果當前group沒有子項,並且也不是展開狀態,就返回DOCKING_HEADER_HIDDEN狀態,不繪製懸停標題;
如果到達了當前group的最後一個子項,進入DOCKING_HEADER_DOCKING狀態;
其他情況,在當前group內部滾動,返回DOCKING_HEADER_DOCKED狀態。
五、Touch事件處理
文章最前面提到過,這個標題視圖是畫上去,而不是添加到view hierarchy裡的,因此它是無法響應touch事件的!那就需要我們自己根據點擊地區進行判斷了,需要重寫onInterceptTouchEvent()和onTouchEvent()方法:
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN && mDockingHeaderVisible) { Rect rect = new Rect(); mDockingHeader.getDrawingRect(rect); if (rect.contains((int)ev.getX(), (int)ev.getY()) && mDockingHeaderState == IDockingController.DOCKING_HEADER_DOCKED) { // Hit header view area, intercept the touch event return true; } } return super.onInterceptTouchEvent(ev); } // Note: As header view is drawn to the canvas instead of adding into view hierarchy, // it‘s useless to set its touch or click event listener. Need to handle these input // events carefully by ourselves. @Override public boolean onTouchEvent(MotionEvent ev) { if (mDockingHeaderVisible) { Rect rect = new Rect(); mDockingHeader.getDrawingRect(rect); switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: if (rect.contains((int)ev.getX(), (int)ev.getY())) { // forbid event handling by list view‘s item return true; } break; case MotionEvent.ACTION_UP: long flatPostion = getExpandableListPosition(getFirstVisiblePosition()); int groupPos = ExpandableListView.getPackedPositionGroup(flatPostion); if (rect.contains((int)ev.getX(), (int)ev.getY()) && mDockingHeaderState == IDockingController.DOCKING_HEADER_DOCKED) { // handle header view click event (do group expansion & collapse) if (isGroupExpanded(groupPos)) { collapseGroup(groupPos); } else { expandGroup(groupPos); } return true; } break; } } return super.onTouchEvent(ev); }這部分實現比較簡單易懂,如果當前是DOCKING_HEADER_DOCKED狀態,並且點擊地區命中了標題視圖的drawing rect,那麼就需要攔截touch事件,並且在手指抬起時根據group當前的狀態執行收合或者展開的動作。
六、更新標題視圖內容
前面5步已經完成了懸停標題狀態機器的控制,但是具體標題列上應該怎麼顯示(比如變更標題文字、顯示收縮展開表徵圖等等),需要使用者來處理。因此定義了一個IDockingHeaderUpdateListener介面,使用者需要實現onUpdate()方法,根據當前的group ID以及收縮展開狀態決定如何更新懸停標題視圖:
public interface IDockingHeaderUpdateListener { void onUpdate(View headerView, int groupPosition, boolean expanded);}在demo該方法的實現就是簡單的更新懸停標題列的文字,具體參見MainActivity。
七、Adapter的資料來源
這部分其實就是給DockingExpandableListViewAdapter又封了一層adapter,因為有些方法實現過了,就把那些需要使用者提供資料的方法單獨拎出來封了一個IDockingAdapterDataSource介面。當然你也可以不用這個介面直接改Adapter,出於介紹的完整性考慮把介面貼在這裡:
public interface IDockingAdapterDataSource { int getGroupCount(); int getChildCount(int groupPosition); Object getGroup(int groupPosition); Object getChild(int groupPosition, int childPosition); View getGroupView(int groupPosition, boolean isExpanded, View convertView, ViewGroup parent); View getChildView(int groupPosition, int childPosition, boolean isLastChild, View convertView, ViewGroup parent);}
最後,也是最重要的部分,源碼:
範例程式碼下載 (CSDN)
https://github.com/qianxin2016/DockingExpandableListView
Android自訂ViewGroup(二)——帶懸停標題的ExpandableListView