Android实战开发手把手教你实现一个头部固定的ExpandableListView

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/geduo_83/article/details/102763631

本文出自门心叼龙的博客,属于原创类容,转载请注明出处。

什么是ExpandabeListView

在上一篇文章我们讲了《View的滑动在自定义View当中的高级应用》自定义实现了一个能左右滑动的ViewPager,今天继续讲关于自定义View的滑动的话题,在琳琅满目的App手机应用当中无处不在的滑动操作,无外乎就是左右滑动和上下滑动,上篇我们研究了左右滑动,今天我们来实现一个头部固定的能上下滑动的自定义View,说到上下滑动我们自然而然就会想到Adnroid系统给我提供的ScrollView、NestedScrollView、ListView、RecycleView,他们共同特点是都表示一个纵向的数据列表,都具备上下滑动的功能。其中NestedScrollView是ScrollView的升级版,可有效的解决ScrollView嵌套ListView,RecycleView所导致的滑动冲突问题,RecycleView是ListView的升级版,有效的解决了数据列表View的缓存问题,增强了列表滚动时的性能。

而今天我们要讲的是ExpandableListView,从名字上看他是一个可扩展的ListView,它应该具备了ListView所拥有的所有功能,我不防看看Google官方对它的权威定义:
在这里插入图片描述
通过官方文档我们很清楚的看到了ExpandalbeListView它的父类是Listview,ExpandalbeListView应该是增强了ListView的功能,它是一个能在垂直滚动的二级列表中显示很多项的View,它和ListView的不同之处在于允许二级列表组能独立的扩展显示他的子View。这些子View来自和他关联的ExpandalbeListAdapter。

有人可能会说,我通过RecycleView的itemType同样也可以实现分组效果,但是在RecycleView当中你点击组头的时候要实现当前组的收缩和展开功能就非常麻烦了,而ExpandalbeListView就天然具备这样的特性。下面我们看看ExpandableListView的基本运行效果,我们通过ExpendableListAdapter给ExpandalbeListView添加数据,初始化了三个组

public class ExpandableListViewAdapter extends BaseExpandableListAdapter {
    private List<Group> mGroups = new ArrayList<>();
    private List<List<Student>> mStudents = new ArrayList<>();
    private Context mContext;

    public ExpandableListViewAdapter(Context context) {
        mContext = context;
        initData();
    }

   private void initData() {
        for (int i = 0; i < 3; i++) {
            mGroups.add(new Group("item_sticky_group-" + i));
        }
        for (int i = 0; i < 3; i++) {
            List<Student> students =  new ArrayList<>();
            if (i == 0) {
                for (int j = 0; j < 20; j++) {
                    students.add(new Student("a"+j,new Random().nextInt(20),"add"+j));
                }
            } else if (i == 1) {
                for (int j = 0; j < 15; j++) {
                    students.add(new Student("b"+j,new Random().nextInt(20),"add"+j));
                }
            } else {
                for (int j = 0; j < 30; j++) {
                    students.add(new Student("c"+j,new Random().nextInt(20),"add"+j));
                }
            }
            mStudents.add(students);
        }
    }
}

我们在ExpandableListViewAdapter的构造方法初始化数据源创建了三个Group组,以及对应每一组的Student,通过设置适配器:

mExpandableListView.setAdapter(new ExpandableListViewAdapter(this));

显示效果如下:
在这里插入图片描述
默认状态下只显示了一级列表,二级列表都处于收缩状态,我们通过设置:

for(int i = 0; i < 3; i++){
            mExpandableListView.expandGroup(i);
 }

使所有的二级列表都处于展开状态,显示效果如下:
在这里插入图片描述

什么是头部固定的ExpandalbeListView

我们通过调用ExpandableListView的expandGroup方法使所有的二级列表都展开了,手指在数据列表上下滑动,每一组的组头也随着整个列表的滚动而滚动,但是这并不是我们需要的效果,很多时候我们的产品还是很变态的,要求在ExpandalbeListView在滚动的时候组头永远固定在列表的头部位置,来增加组的显示效果,并且随着列表的滚动组头的标题内容也进行实时刷新,即显示为当前所在组的组头信息,并且两个组头相连的时候两个组头能根据列表的滑动进行跟随滑动,谷歌的Android工程师们并没有实现这样的功能,这种情况下那就只能我们自定义实现这样的功能了,具体效果如下:
在这里插入图片描述

功能解析

这个头部固定在顶部且在两个组头相连的情况下Header能够跟随列表的滑动而滑动,效果看似简单,其实他也是有些难度的。我们让ExpandableListView自有的组头固定在顶部显然这是不现实的,在上面的效果图我们可以看到自有组头是随着列表的滚动而滚动的,他也是作为一个普通的item而存在的,它并不能固定在头部,既然无法用系统自带的组头来实现,那么我们能不能自己添加一个组头呢?当然可以,也就是说在整个ExpandableListView列表的上面自定义添加一个Header,也就是给我们组头创建一个公共的替身,并让他固定在列表顶部,在列表的滚动过程不断的刷新组头的title,并且在两个组头相连的时候自定义的组头header随着列表的滚动进行跟随,在视觉效果看来组头始终在顶部固定着,其实我们使用用了一个替身header,往上滚动两个组头相连的时候:真实的Header已经滚到上边去了,只是替身Header在顶部固定着,往下滚动两个组头相连的时候:只是替身Header随着列表的滑动而慢慢全部展现出来,而且在点击Header的时候要实现当前组的展开和收缩功能。现在我们总结一下所有要实现的功能要点:

  • 1.添加自定义Header,并且固定在头部
  • 2.滚动的时候要刷新Header的title内容
  • 3.滚动的时候两组头相连的时候Header能够上下跟随滚动
  • 4.自定义Header在点击的时候能展开、收缩其子列表

ExpandalbeListView的顶部添加Header

下面我们就分别实现这四个功能点,首先我们看第一个功能点:添加自定义Header,并且固定在头部,这个功能应该并不难实现,因为ExpendableListView通过继承关系我们也可以知道它也是一个ViewGroup,ViewGroup它有一个非常重要的方法就是dispatchDarw,我们看看官方文档对它的权威定义:
在这里插入图片描述
意思是说dispatchDraw被draw方法调用,用来绘制子View的,因此我们通过继承ExpandalbeListView复写它的dispatchDraw方法,在其内部实现添加header的功能,添加之后就要解决是他的位置问题,那就是lauyout方法做的工作了,我看具体的代码实现:

public class PinnedHeaderExpandableListView extends ExpandableListView {

   public PinnedHeaderExpandableListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }
     ...
     ...  
  public void showPinnedHeaderView(){
        mPinnedHeader = LayoutInflater.from(getContext()).inflate(R.layout.item_sticky_group, null);
        mPinnedHeader.setLayoutParams(new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT,RelativeLayout.LayoutParams.WRAP_CONTENT));
        mTxtHeaderTitle = mPinnedHeader.findViewById(R.id.group);
        mTxtHeaderTitle.setText("item_sticky_group-1");
        requestLayout();
        postInvalidate();
}
    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        if(mPinnedHeader == null){
            return;
        }
        drawChild(canvas,mPinnedHeader,getDrawingTime());
    }
    ...
    ...
}

在以上的代码片段的第9行showPinnedHeaderView方法创建了自定义的Header,第18行的dispatchDraw方法将hader添加到ViewGroup当中去,添加进入之后我们要做的事情就是给header定位,确定他的位置left,top很显然都是0,right毫不含糊就是header的宽,bottom就是header的高,代码实现如下:

public class PinnedHeaderExpandableListView extends ExpandableListView {
    public PinnedHeaderExpandableListView(Context context) {
        super(context);
        initView();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        if (mPinnedHeader == null) {
            return;
        }
        measureChild(mPinnedHeader, widthMeasureSpec, heightMeasureSpec);
        mPinnedHeaderWidth = mPinnedHeader.getMeasuredWidth();
        mPinnedHeaderHeight = mPinnedHeader.getMeasuredHeight();
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        if (mPinnedHeader == null) {
            return;
        }
        mPinnedHeader.layout(0, 0, mPinnedHeaderWidth, mPinnedHeaderHeight);
    }

在onMeasure方法中,也就是13行我们通过measureChildren方法测量header的宽和高,在onLayout方法中,也就是代码的24行通过调用mPinnedHeader.layout来确定他的位置,截止现在第一个功能点我们就实现了,现在我们来看下实现的效果:
在这里插入图片描述

ExpandalbeListView滚动的时候实现Header的刷新

看着效果还不错,我们已经实现了第一个功能点,但是有个问题就是列表在滚动的过程中Header的title并没有更新,现在就来实现这个功能,列表的滚动过程更新,我们很容易联想到它的滚动监听器OnScrollListener,我看看他的官方定义:
在这里插入图片描述
意思是说他是一个接口,当列表或表格在滚动的时候他的回调将会被调用,下图中的onScroll方法就是我们所关心的方法
在这里插入图片描述
onScroll共有四个参数

参数 含义
view 就是我们的ExpandableListView
firstVisibleItem 第一个可视条目的原始下标
visibleItemCount 可视范围类列表条目的个数
totalItemCount 列表当中总的条目个数

很显然我需要的是第一个第一个参数firstVisibleItem,我们可以通过它利用ExpandableListView所提供的getExpandableListPosition方法获取它的包装下标,通过包装下标我们调用getPackedPositionGroup方法可以获取它所在的组的下标,有了组下标我们就可以更新header的title了,具体实现如下:

public class PinnedHeaderExpandableListView extends ExpandableListView {

    public PinnedHeaderExpandableListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }
    ...
    ...
    
    public void initView() {
        setOnScrollListener(new OnScrollListener() {
            @Override
            public void onScrollStateChanged(AbsListView view, int scrollState) {

            }

            @Override
            public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
                if (mPinnedHeader == null) {
                    return;
                }
                int firstGroup = getPackedPositionGroup(getExpandableListPosition(firstVisibleItem));
                mTxtHeaderTitle.setText("item_sticky_group-" + firstGroup);
            }
        });
    }
    ...
    ...
}

我们在PinnedHeaderExpandableListView的初始化方法initView当中给它设置了监听器OnScrollListener,这样在滚动的过程中我们就可以不断的刷新Header的title了,在onScroll方法中,代码片段的22行,我们通过计算得到当前顶部条目所在的组,在23行就可以更新header的title了,我们来看下所实现的效果:
在这里插入图片描述

ExpandalbeListView滚动的时候实现Header的跟随滚动

很完美我们已经实现了列表在滚动的过程中header的自动刷新了 ,但是美中不足就是没有实现当两个组头相连的时候,没有实现跟随header的跟随效果,我们怎么判断连个组头相连?我们想了onScroll方法回调的参数firstVisibleItem+1通过它获取它的包装下标,通过包装坐标再去获取它的组下标:nextGroup,让他和我们前面所计算的组坐标firstGroup做个比较将何如?此时我们恍然大悟:只要nextGroup == firstGroup +1那么两个组头必然相连,此时我们去滑动header的位置,我们通过调用它的layout方法来改变他的位置,right,left的值并没有发生变化,只是top和bottom在不断的发生改变,移动了多少距离?此时我们可以获取View view = getChildAt(1)子view,移动的高度=header的高度-view.top()就是所移动的距离,也就是top,那么bottom = header的高度-移动的高度,完整代码如下:

public class PinnedHeaderExpandableListView extends ExpandableListView {

    public PinnedHeaderExpandableListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }
    ...
    ...

    public void initView() {
        setOnScrollListener(new AbsListView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(AbsListView view, int scrollState) {

            }

            @Override
            public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
                //int firstVisiblePosition = getFirstVisiblePosition();
                if (mPinnedHeader == null) {
                    return;
                }
                int firstGroup = getPackedPositionGroup(getExpandableListPosition(firstVisibleItem));
                int nextPosition = firstVisibleItem + 1;
                int nextGroup = getPackedPositionGroup(getExpandableListPosition(nextPosition));
                View childView = getChildAt(1);
                if (childView == null) {
                    return;
                }
                int top = childView.getTop();
                if (nextGroup == firstGroup + 1) {
                    if (top <= mPinnedHeaderHeight) {
                        int delta = mPinnedHeaderHeight - top;
                        mPinnedHeader.layout(0, -delta, mPinnedHeaderWidth, mPinnedHeaderHeight - delta);
                    } else {
                        mPinnedHeader.layout(0, 0, mPinnedHeaderWidth, mPinnedHeaderHeight);
                    }
                } else {
                    mPinnedHeader.layout(0, 0, mPinnedHeaderWidth, mPinnedHeaderHeight);
                }
                mTxtHeaderTitle.setText("item_sticky_group-" + firstGroup);
            }
        });
    }
    ...
    ...
}

其实从31行到41行这10行才是整个自定义view的核心代码,完美的实现了两个组头相连时heder随着列表的跟随滚动,现在我们看下运行的效果:
在这里插入图片描述

Header点击的时候实现展开折叠

截止目前真个自定义的View的核心功能都实现完毕了,不要高兴太早,由于我们的header是自定义添加的,因此他的点击事件并不能响应ExpandableListView的展开收缩事件,这个问题我们要解决的是怎么判断我们的点击事件是落在了header上了,首先我们可以通过事件分发方法dispatchTouchEvent(MotionEvent ev)获取当前点击的位置的x,y坐标,如果y >= mPinnedHeader.getTop() && y <= mPinnedHeader.getBottom()将何如?这就表示点击事件路落在了header上,那么怎么知道他所在的组呢?通过x,y的坐标再调用ExpandableListView的pointToPosition(x, y)方法就可以得到当前的原始坐标了position,最后再通过getPackedPositionGroup(getExpandableListPosition(position))就可以知道他所在的组了,知道了组就可以调用ExpandableListView的expandGroup方法和collapseGroup方法来展开、收缩具体的组了,具体的代码实现如下:

public class PinnedHeaderExpandableListView extends ExpandableListView {

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_UP:
                if (mPinnedHeader != null && y >= mPinnedHeader.getTop() && y <= mPinnedHeader.getBottom()) {
                    int position = pointToPosition(x, y);
                    int positionGroup = getPackedPositionGroup(getExpandableListPosition(position));
                    if (positionGroup != INVALID_POSITION) {
                        if (isGroupExpanded(positionGroup)) {
                            collapseGroup(positionGroup);
                        } else {
                            expandGroup(positionGroup);
                        }
                    }
                    return true;
                }
                break;
        }
        return super.dispatchTouchEvent(ev);
    }
}

第9行 mPinnedHeader != null && y >= mPinnedHeader.getTop() && y <= mPinnedHeader.getBottom()就是我们判断点击事件是否落在header的核心逻辑,10到11行就是获取当前所点击的位置所在是哪个组,12行到18行就是实现展开和收缩的核心逻辑代码。现在我们看下具体的实现效果:
在这里插入图片描述
以上就实现的最终效果,通过一步步的分析,对复杂功能的一步步分解,最终把所有的效果都实现了,终于大功告成,最后我将整个测试代码传到了github上,欢迎学习下载https://github.com/mxdldev/android-custom-view/,其中PinnedHeaderExpandableListView.java就是我们本例中的自定义View的全部代码实现,下载完整项目后直接运行安装完毕,点击PinnedHeaderExpandableListView按钮就进入了我们的测试页面,效果图如下:
在这里插入图片描述

问题反馈

在使用学习中有任何问题,请留言,或加入Android、Java开发技术交流群

猜你喜欢

转载自blog.csdn.net/geduo_83/article/details/102763631