Android自訂ViewGroup(二)——帶懸停標題的ExpandableListView

來源:互聯網
上載者:User

標籤:而不是   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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.