Android RecyclerView-使用Itemdecoration实现粘性头部功能,详细到具体步骤.

4,829 阅读11分钟

一 前言

该文详细的介绍了RecyclerView.ItemDecoration实现分组粘性头部的功能,让我们自己生产代码,告别代码搬运工的时代.另外文末附有完整Demo的连接.看下效果:

二 知识准备

RecyclerView.ItemDecoration对于我们最熟悉的功能就是给RecyclerView实现各种各样自定义的分割线了,实现分割线的功能其实和实现粘性头部的功能大同小异,那我们就来看看这神奇的RecyclerView.ItemDecoration.

该类是RecyclerView的内部静态抽象类:

 public abstract static class ItemDecoration {
       /**
        * 绘制*除Item内容*以外的布局,这个方法是再****Item的内容绘制之前****执行的,
        * 所以呢如果两个绘制区域重叠的话,Item的绘制区域会覆盖掉该方法绘制的区域.
        * 一般配合getItemOffsets来绘制分割线等.
        *
        * @param c      Canvas 画布
        * @param parent RecyclerView
        * @param state  RecyclerView的状态
        */
        public void onDraw(Canvas c, RecyclerView parent, State state) {
            onDraw(c, parent);
        }
        @Deprecated
        public void onDraw(Canvas c, RecyclerView parent) {
        }

        /**
         * 绘制*除Item内容*以外的东西,这个方法是在****Item的内容绘制之后****才执行的,
         * 所以该方法绘制的东西会将Item的内容覆盖住,既显示在Item之上.
         * 一般配合getItemOffsets来绘制分组的头部等.
         *
         * @param c      Canvas 画布
         * @param parent RecyclerView
         * @param state  RecyclerView的状态
         */
        public void onDrawOver(Canvas c, RecyclerView parent, State state) {
            onDrawOver(c, parent);
        }

        /**
         * @deprecated
         * Override {@link #onDrawOver(Canvas, RecyclerView, RecyclerView.State)}
         */
        @Deprecated
        public void onDrawOver(Canvas c, RecyclerView parent) {
        }


        /**
         * @deprecated
         * Use {@link #getItemOffsets(Rect, View, RecyclerView, State)}
         */
        @Deprecated
        public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
            outRect.set(0, 0, 0, 0);
        }

        /**
         * 设置Item的布局四周的间隔.
         *
        * @param outRect 确定间隔 Left  Top Right Bottom 数值的矩形.
        * @param view    RecyclerView的ChildView也就是每个Item的的布局.
        * @param parent  RecyclerView本身.
        * @param state   RecyclerView的各种状态.
         */
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
            getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                    parent);
        }
    }

这里面呢有个问题一定要明白几个问题:

  • getItemOffsets这个方法设置的Item间隔到底是那个间隔?

    我们来看一张图.

我们知道getItemOffsets()第一个参数是一个矩形的对象,这个对象的left、 top、right、bottpm四个属性值分别表示图中的outRect.left、outRect.top、outRect.right、outRect.bottom四个线段所表示的空间.也就是说当RecyclerView的Item再确定自己的大小的时候会将getItemOffsets()里面的Rect对象的Left、Top、Right、Bottom属性取出来,看看需要再Item布局的四周留出多大的空间.我们来看下源码:

Rect getItemDecorInsetsForChild(View child) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (!lp.mInsetsDirty) {
            return lp.mDecorInsets;
        }

        if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
            // changed/invalid items should not be updated until they are rebound.
            return lp.mDecorInsets;
        }
        final Rect insets = lp.mDecorInsets;
        insets.set(0, 0, 0, 0);
        final int decorCount = mItemDecorations.size();
        for (int i = 0; i < decorCount; i++) {
            mTempRect.set(0, 0, 0, 0);
        //这里呢mTempRect就是我们再getItemOffsets()里面的第一个Rect的对象,我们再实现类的方法里面给mTempRect赋值.
            mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
            insets.left += mTempRect.left;
            insets.top += mTempRect.top;
            insets.right += mTempRect.right;
            insets.bottom += mTempRect.bottom;
        }
        lp.mInsetsDirty = false;
        return insets;
    }
    
    
    这里呢就是RecyclerView再测量每个Child的大小的时候都把insets这个矩形的l t r  b 数值都加上了.insets就是方法getItemDecorInsetsForChild()返回的矩形对象.
     /**
         * Measure a child view using standard measurement policy, taking the padding
         * of the parent RecyclerView and any added item decorations into account.
         *
         * <p>If the RecyclerView can be scrolled in either dimension the caller may
         * pass 0 as the widthUsed or heightUsed parameters as they will be irrelevant.</p>
         *
         * @param child Child view to measure
         * @param widthUsed Width in pixels currently consumed by other views, if relevant
         * @param heightUsed Height in pixels currently consumed by other views, if relevant
         */
        public void measureChild(View child, int widthUsed, int heightUsed) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();

            final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
            widthUsed += insets.left + insets.right;
            heightUsed += insets.top + insets.bottom;
            final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
                    getPaddingLeft() + getPaddingRight() + widthUsed, lp.width,
                    canScrollHorizontally());
            final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
                    getPaddingTop() + getPaddingBottom() + heightUsed, lp.height,
                    canScrollVertically());
            if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
                child.measure(widthSpec, heightSpec);
            }
        }

源码的讲解过于粗糙,希望大家见谅,目的就是为了让大家知道这个getItemOffsets()方法是怎么让RecyclerView再Item之外留出空间的.

  • onDraw()和onDrawOver()方法应该用哪一个?

    首先我们看过上面的代码之后知道,onDraw执行再Item的绘制之前,也就是ItemDecoration的onDraw方法先执行,再执行Item的onDraw方法,这样Item的内容就会覆盖在ItemDecoration的onDraw上面.ItemDecoration的onDrawOver()方法执行在Item的绘制之后,那就是onDrawOver()绘制的内容会覆盖再Item内容之上.这样就形成了层层遮盖的问题,那么我们平常的分割线通常绘制在ItemDecoration的onDraw()方法里面,为了避免Item的内容覆盖掉,我们就要getItemOffsets()为我们留出绘制的空间了.这样我们的思路不是不有了呢.

    我们可以用onDrawOver()和getItemOffsets()方法一起使用来实现Item的粘性头部和顶部悬浮的效果.

    三 代码部分

    需求分析:这部分其实是写代码前尤为重要的一部分,再分析的过程中你可以知道我们要完成的是哪些功能,用什么东西去完成,怎么才能更好的去完成.最后自己能确定出一套完美实现需求的方案.

    我们要做的是区域分组显示,每个分组的开始要有一个粘性头部.如图所示:

  • 数据准备

首先后台返回的数据一定要有组类区分,每个分组的标记不能一样,最好是我们方便处理的.该Demo采用的标记位是int类型的标记tag,每组的标记以此+1,每五个城市分为一组,每组的第一个城市当做头部局显示的内容.我们的分组头部的高度为40dp.

  • getItemOffsets()
    该方法再recyclerView的每个Item测量大小的时候都会被调用到, 我们要在该方法里面判断出那个HeadItem并且给HeadItem留出绘制的空间,这里有两种方式.
    第一种方式:
    给Item 的Top留出空间,也就是outRect.top属性赋值.
    第二种方式:
    给Item 的Bottom留出空间也就是outRect.bottpm属性赋值.
    因为我们在列表一开始的时候就要绘制一次Head,也就是说我们要留出Head的空间,那么我们只能选择第一种方法去预留空间了. 当你选择方式1的时候,给outRect.top赋值,这样的话我们判断是否是HeadItem的话就要拿当前Item的标记跟前一个Item的标记判断了.如果用第二种的话就要用当前的标记跟下一个Item的标记判断了.
    下面我来解释下第一种方式,第二种方式雷同:
    a b c d e f g h i
    分组1 abc
    分组2 def
    分组3 ghi
    如果 a d g 是HeadItem . a的tag = 1 , b的tag = 1, c 的tag = 1....d的tag = 2,e的tag = 2 ,f的tag = 2,g的tag = 3...等等 .
    前一个Item的tag用 preTag 来表示 ,初始值为 -1.
    假如当前的Item为a,当前tag = 1,那么它的前一个Item为空,也就是发现preTag和a的tag不一样,那么a就是分组的头部.
    假如当前的Item为b,当前tag = 1,那么它前一个preTag 也就是a的tag = 1,发现一样那就是是同一组的.
    假如当前的Item为d,当前tag = 2,那么它前一个preTag 也就是c的tag = 1,发现前一个的tag跟当前的不一样,那么当前的就是新分组的第一个头部Item.代码是最有说服力的,下面来看代码:
@Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        if (citiList == null || citiList.size() == 0) {
            return;
        }
        int adapterPosition = parent.getChildAdapterPosition(view);
        RecBean.CitiListBean beanByPosition = getBeanByPosition(adapterPosition);
        if(beanByPosition == null){
            return;
        }
        int preTage = -1;
        int tage = beanByPosition.getTage();
        //一定要记住这个 >= 0
        if(adapterPosition - 1 >= 0) {
            RecBean.CitiListBean nextBean = getBeanByPosition(adapterPosition - 1);
            if (nextBean == null) {
                return;
            }
            preTage = nextBean.getTage();
        }
        if(preTage != tage){
            outRect.top = headHeight;
        }else {
            //这个目的是留出分割线
            outRect.top = lineHeight;
        }

    }

这样下来我们给分组头部的空间就预留出来了.接下来绘制分组头部,因为分割线我直接显示的背景色所以就不用去绘制分割线了.

  • onDrawOver()
    这个方法里面我们要做的不只是绘制Head,当列表滑动的时候RecyclerView会不断的加载之后的Item,布局发生复用,我们要在不断的变化中去重新绘制我们的HeadItem的布局.这个方法当每个Item消失或者出现的时候都会被调用,我们在这里去绘制HeadItem的区域.所以在该方法里面我们会遍历所有可见的Item去重新判断那个Item有Head,然后去绘制.
    1.判断头布局绘制头布局 ?
    那么我们在这里呢还是需要判重新去判断哪个Item是有Head.按照getItemOffsets里面的我们需要跟之前的Item的tag做比较.但是有个问题就是我们再这里并不能拿到Item的布局或者别的东西,只能遍历所有已经显示的Item,也就是只能一个个的将RecyclerView的ChildView拿出来.这样的话我们的前一个preTag就需要我们自己去定义,然后用preTag来记录我们遍历过的ChildView的Tag,当遍历到下个Item的Tag跟之前的preTag一样的话,那就继续遍历不去绘制头布局,当遍历到Item的tag跟preTag不一样的时候就去绘制有布局.因为滑动的Item都会作为RecyclerView的第0个ChildView出现,我们拿不到它之前的Item的tag.
    2.怎么让头布局悬停在顶部 ?
    这个问题其实拿一个场景去说明是最好的了.当我们HeadItem正好出现在屏幕的顶部的时候,我们继续滑动列表HeadItem就会渐渐的消失,也就是Item的getTop距离会小于我们HeadItem的Head的高度,当出现这种情况的时候, 我们就让Item的getTop和Head的高度中去选择一个最大值.这样就好保证当HeadItem画出屏幕的时候Head布局一直留在顶部.
    3.下个头部来的时候怎么替换呢 ?
    当顶部有一个头部局在悬停的时候,我们滑动列表时下个头部肯定会和当前悬停的头部相遇.我们再这里做的是当前悬浮的头布局跟下个头布局相遇发生交替的时候有个渐变的效果.因为该方法在每一个Item出现或者消失的时候都会执行,每当执行的时候都要遍历一遍当前已经显示的Item布局,那么一定会出现,当前遍历的第0个Item正好是屏幕中的第一个Item,它的下一个Item正好是分组的头布.这样的话再往前滑的时候就会出现头部交替的情况.我们这里就需要判断下一个Item是不是有头布局的Item,比较的方法就是用当前Item数据的tag跟下一个Item数据的nextTag比较,如果不同的话那下个Item就是有头布局的.如果一样的话就continue继续遍历.再列表滑动的时候回一直绘制所有可见Item的Head布局.
    4.渐变效果呢?
    上面我们知道了当下个HeadItem跟屏幕顶部的Head相遇的时候就要发生交替.交替的时候有个渐变效果,也就是之前再屏幕顶部悬停的Head要随着一个Item的消失而消失.下个Head要滑动到屏幕之后停在那里.那就好办了当onDrawOver()方法执行的时候,RecyclerView的第0个ChildView正好是屏幕顶部的Item,当它的下一个Item有个Head的时候,我们只需要将当前Item的getTop数值赋值给绘制Head的矩形的bottpm属性就可以了.我们一定要明白当出现Item出现消失的时候Head是再不断的绘制的.

上代码:

 @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        if(citiList == null || citiList.size() == 0){
            return;
        }

        int parentLeft = parent.getPaddingLeft();
        int parentRight = parent.getWidth() - parent.getPaddingRight();

        int childCount = parent.getChildCount();
        int tag = -1;
        int preTag;
        for (int i = 0; i <childCount; i++) {
            View childView = parent.getChildAt(i);
            if(childView == null){
                continue;
            }
            int adapterPosition = parent.getChildAdapterPosition(childView);
            当前Item的Top
            int top = childView.getTop();
            int bottom = childView.getBottom();
            preTag = tag;
            tag = citiList.get(adapterPosition).getTage();
            //判断下一个是不是分组的头部
            if(preTag == tag){
                continue;
            }
            //这里面我把每个分组的头部显示的文字列表单独提出来了,为了测试方便用,
            String name = index.get((tag - 1 ) < 0 ? 0 : (tag -1));
            int height = Math.max(top,headHeight);
            //判断下一个Item是否是分组的头部
            if(adapterPosition + 1 < citiList.size()){
                int nextTag = citiList.get(adapterPosition + 1).getTage();
                if(tag != nextTag){
                   //这里就是实现渐变效果的地方
                   //因为如果遍历到
                    height = bottom;
                }
            }
            paint.setColor(Color.parseColor("#ffffff"));
            c.drawRect(parentLeft,height - headHeight,parentRight,height,paint);
            paint.setColor(Color.BLACK);
            paint.getTextBounds(name, 0, name.length(), rectOver);

            c.drawText(name, dip2px(10), height - (headHeight - rectOver.height()) / 2, paint);

        }


    }

到这里我们的功能已经结束了,我们要知道getItemOffsets()会提前执行,每个Item的回收和出现都会执行一次.onDraw或者onDrawOver再屏幕中的Item发生变化的时候都会执行,只要发生变化.我们的Head会不停的绘制.

结束

这是2018年的第一篇文章,之前太忙了也没好好的总结知识点.写的仓促希望大家多多指导文章出现的问题,谢谢大家的反馈,欢迎评论吐槽哦~

欢迎大家关注
我的掘金
我的CSDN
我的简书

Demo下载

喜欢文字的同学也可以关注该公众号,与你一起同游文字的海洋~