zoukankan      html  css  js  c++  java
  • Android自己定义ViewGroup(二)——带悬停标题的ExpandableListView

    项目里要加一个点击可收缩展开的列表,要求带悬停标题,详细效果例如以下图:


    也就是说。在某一个分组内部滚动时,要求分组标题悬停。当滚出该分组范围时,把标题顶出去。悬停下一个分组的标题。正好看到一个比較有趣的思路。做了一个实现,在这里分享一下。

    代码结构例如以下。基本上是一个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;
        }
    逻辑很easy清晰:

    假设当前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

  • 相关阅读:
    BZOJ3779: 重组病毒
    BZOJ3112 [ZJOI2013]防守战线
    BZOJ4011 [HNOI2015]落忆枫音
    BZOJ2726 [SDOI2012]任务安排
    BZOJ1492 [NOI2007]货币兑换
    BZOJ1597 [USACO2008]土地购买
    BZOJ3611 [HEOI2014]大工程
    BZOJ3991 [SDOI2015]寻宝游戏
    BZOJ3675 [APIO2014]序列分割
    BZOJ1010 [HNOI2008]玩具装箱
  • 原文地址:https://www.cnblogs.com/llguanli/p/8880574.html
Copyright © 2011-2022 走看看