RecyclerView 悬浮/粘性头部——StickyHeaderDecoration

前言

ItemDecoration是recyclerView拓展的一个很好工具,支持我们在recyclerView上面做各种操作,而且耦合性低,容易添加。这篇我们先用ItemDecoration来做悬浮/粘性头部,后面还可以用ItemDecoration做时间轴,手机通讯录联系人右侧字母导航栏。


老规矩,先上图。




集成方式

github地址:[https://github.com/qdxxxx/StickyHeaderDecoration](https://github.com/qdxxxx/StickyHeaderDecoration"optional title")
天气热,本github已安装空调,star即可免费享用~~

  • 注入依赖
    Step 1. Add the JitPack repository to your build file
    Step 2. Add the dependency
	allprojects {
		repositories {
			...
			maven { url 'https://jitpack.io' }
		}
	}
	dependencies {
 	   compile 'com.github.qdxxxx:StickyHeaderDecoration:1.0.1'
	}

Activity里面集成代码

  • 分组头部
        NormalDecoration decoration = new NormalDecoration() {
            @Override
            public String getHeaderName(int pos) {
                return //返回每个分组头部名称;
            }
        };
  • 自定义头部/悬浮头部layout】【自定义头部加载图片请用 loadImage()方法】
        decoration.setOnDecorationHeadDraw(new NormalDecoration.OnDecorationHeadDraw() {
            @Override
            public View getHeaderView(int pos) {
                return //返回自定义头部view;
            }
        });
  • 头部点击事件
        decoration.setOnHeaderClickListener(new NormalDecoration.OnHeaderClickListener() {
            @Override
            public void headerClick(int pos) {
            }
        });

GridLayoutManager请配合GridDecoration使用。


方法及属性介绍


name format 中文解释
setHeaderHeight integer 分组头部高度
setTextPaddingLeft integer 普通分组头部【只含文字】文字左边距
setTextSize integer 普通分组头部【只含文字】文字大小
setTextColor integer 普通分组头部【只含文字】文字颜色
setHeaderContentColor integer 普通分组头部【只含文字】文字背景颜色
onDestory 清空数据集合/监听等
*loadImage String,integer,ImageView 用来加载并刷新图片到分组头部【自定义头部很重要的方法!】


实现解刨

又要开始漫天代码的解刨了,非专业战斗人员…请务必耐着性子看。
首先我们来划分几个主要的功能模块

  • 预留不同分组的头部空间
  • 绘制不同分组头部
  • 绘制悬浮头部
  • 悬浮头部粘性效果(上推效果)
  • 头部点击处理
  • 自定义layout的头部
  • GridLayoutManager的适配

进击的ItemDecoration

以下一个段落引用【带心情去旅行】的简书,写的很具体。

先看下RecyclerView.ItemDecoration的源码(部分):

public static abstract class ItemDecoration {
    ...
    public void onDraw(Canvas c, RecyclerView parent, State state) {
        onDraw(c, parent);
    }
    public void onDrawOver(Canvas c, RecyclerView parent, State state) {
        onDrawOver(c, parent);
    }
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
        getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                parent);
    }
}

里面是我们常用的三个方法:

  • getItemOffsets:通过Rect为每个Item设置偏移,用于绘制Decoration。
  • onDraw:通过该方法,在Canvas上绘制内容,在绘制Item之前调用。(如果没有通过getItemOffsets设置偏移的话,Item的内容会将其覆盖)
  • onDrawOver:通过该方法,在Canvas上绘制内容,在Item之后调用。(画的内容会覆盖在item的上层)

RecyclerView 的背景、onDraw绘制的内容、Item、onDrawOver绘制的内容,各层级关系如下:

RecyclerView 悬浮/粘性头部——StickyHeaderDecoration_第1张图片

表示感谢【带心情去旅行】


预留不同分组的头部空间

我们为每个不同头部名称的第一个item设置头部高度

根据上面的讲解,我们用getItemOffsets()方法设置分组的item头部,我们只要判断当前item和上一个item是否属于同一个group即可。

        /*我们为每个不同头部名称的第一个item设置头部高度*/
        int pos = parent.getChildAdapterPosition(itemView); //获取当前itemView的位置
        String curHeaderName = getHeaderName(pos);         //根据pos获取分组头部名
        
        if (pos == 0 || !curHeaderName.equals(getHeaderName(pos - 1))) {//如果当前位置为0,或者与上一个item头部名不同的,都腾出头部空间
            outRect.top = headerHeight;                                 //设置itemView PaddingTop的距离
        }

绘制不同分组头部

onDrawOver()来绘制分组头部,相当于绘制在item的界面之上(因为item已经设置了偏移)
和上述方法一样,我们先获得每个分组的位置,然后绘制文字即可(自定义layout亦是如此)

  • 我们先获取当前屏幕所有recyclerView显示的item
  • 如果头部距离顶部==2*headerHeight时,悬浮头部就要向上偏移(上推效果)
  • 头部距离顶部==headerHeight时,悬浮头部偏移headerHeight(推离屏幕效果)
    RecyclerView 悬浮/粘性头部——StickyHeaderDecoration_第2张图片
        int childCount = recyclerView.getChildCount();//获取屏幕上可见的item数量
        for (int i = 0; i < childCount; i++) {
            View childView = recyclerView.getChildAt(i);
            int pos = recyclerView.getChildAdapterPosition(childView); //获取当前view在Adapter里的pos
            String curHeaderName = getHeaderName(pos);                 //根据pos获取要悬浮的头部名
            int viewTop = childView.getTop() + recyclerView.getPaddingTop();
            if (pos == 0 || !curHeaderName.equals(getHeaderName(pos - 1))) {//如果当前位置为0,或者与上一个item头部名不同的,都腾出头部空间
                //绘制每个组头【奥迪上头的a(阿尔法罗密欧上头就不用绘制a),本田上头的b】
                
            canvas.drawRect(left, viewTop - headerHeight, right, viewTop, mHeaderContentPaint);//绘制头部背景
            canvas.drawText(curHeaderName, left + textPaddingLeft, viewTop - headerHeight / 2 + txtYAxis, mHeaderTxtPaint);//绘制文字,文字的基线可以看我的自定义菜单,有说到
                
                if (headerHeight < viewTop && viewTop <= 2 * headerHeight) { //此判断是刚好2个头部碰撞,悬浮头部就要偏移
                    translateTop = viewTop - 2 * headerHeight;//悬浮头部需要偏移的距离(y轴方向)
                }
                
stickyHeaderPosArray.put(pos, viewTop);//将头部信息放进array,【头部点击处理有讲解】
            }
        }


绘制悬浮头部

通过上面的方法,我们就能绘制出每个分组的头部。最后我们绘制一次悬浮的头部

        canvas.save();
        canvas.translate(0, translateTop);
        canvas.drawRect(left, 0, right, headerHeight, mHeaderContentPaint);
        canvas.drawText(firstHeaderName, left + textPaddingLeft, headerHeight / 2 + txtYAxis, mHeaderTxtPaint);
//      canvas.drawLine(0, headerHeight / 2, right, headerHeight / 2, mHeaderTxtPaint);//画条线看看文字居中不
        canvas.restore();


头部点击处理

头部点击这个一开始的确有点棘手,因为这个分组的头部是我们额外绘制上的,就必须要通过自己的计算和存储头部信息。
我们在绘制头部的时候,通过SparseArray将头部信息存储集合里,但是每onDrawOver的时候都要clear一下,确保头部数据正确。
最后通过GestureDetector来处理用户触摸事件,根据用户触摸的y轴位置来判断SparseArray是否包含该位置。

        @Override//单击事件
        public boolean onSingleTapUp(MotionEvent e) {
            for (int i = 0; i < stickyHeaderPosArray.size(); i++) {
                int value = stickyHeaderPosArray.valueAt(i);
                float y = e.getY();
                if (value - headerHeight <= y && y <= value) {//如果点击到分组头
                    if (headerClickEvent != null) {
                        headerClickEvent.headerClick(stickyHeaderPosArray.keyAt(i));
                    }
                    return true;
                }
            }
            return false;
        }

自定义layout的头部

绘制自定义layout的头部有2个要点

  • 如何将layout布局绘制到canvas上
  • 如果layout里有图片,图片加载完成后需要通知canvas刷新,以显示头部图片(否则需要用户滑动才能更新图片)
绘制view到canvas

我们可以通过view.setDrawingCacheEnabled(true)方法,通过cache将view转化为bitmap,在用headerView.getDrawingCache()获取bitmap对象。

    View headerView = headerDrawEvent.getHeaderView(firstPos);
    headerView.measure(//measure布局
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
    headerView.setDrawingCacheEnabled(true);
    headerView.layout(0, 0, right, headerHeight);//布局layout
    canvas.drawBitmap(headerView.getDrawingCache(), left, 0, null);

但是如果view里面包含图片的话,图片不太可能是我们事先存储好的,而是通过网络请求获得的图片url,然后再加载。所以这也是一个难点之一。


通过url绘制图片到头部
  • 如果图片暂未加载完成,通过Glide加载,加载完成后通过map集合来存储图片。
  • 图片加载完成后用mRecyclerView.postInvalidate(),从而间接性的手动调用onDrawOver()方法,重新绘制已经加载好的图片。
public void loadImage(final String url, final int pos, ImageView imageView) {
        if (imgDrawableMap.get(url) != null) {//如果图片已经加载过了,并且已经存储
            imageView.setImageDrawable(imgDrawableMap.get(url));
        } else {
            Glide.with(mRecyclerView.getContext()).load(url).into(new SimpleTarget() {
                @Override
                public void onResourceReady(Drawable resource, Transition transition) {
                  
                    headViewMap.remove(pos);//删除,重新更新
                    imgDrawableMap.put(url, resource);
                    mRecyclerView.postInvalidate();
                }
            });
        }

    }

更多详细功能请移步NormalDecoration,并配合onDrawOver()解析。
所以自定义layout有图片请务必使用loadImage()方法,以便及时讲加载完的图片绘制到界面上。




GridLayoutManager的适配

GridGridDecoration也有2个难点突破

  • 设置item的getItemOffsets,不仅仅是分组头
  • 设置当前分组的最后一个item的Span.
  • 其它的就不需要我们设置了,normalDecoration已经帮我们完成了【自信回头】

RecyclerView 悬浮/粘性头部——StickyHeaderDecoration_第3张图片

public abstract class GridDecoration extends NormalDecoration {
    private int itemTotalCount;

    public GridDecoration(int itemTotalCount, int span) {
        this.itemTotalCount = itemTotalCount;
        for (int pos = 0; pos < itemTotalCount; pos++) {
            /*我们为每个不同头部名称的第一个item设置头部高度*/
            String curHeaderName = getRealHeaderName(pos);         //根据j获取要悬浮的头部名
            if (!headerPaddingSet.contains(pos) && (pos == 0 || !curHeaderName.equals(getRealHeaderName(pos - 1)))) {//如果是分组头部
                groupHeadPos.add(pos);
                for (int i = 0; i < span; i++) {
                    headerPaddingSet.add(pos + i);
                    if (!curHeaderName.equals(getRealHeaderName(pos + i + 1))) {//如果下一个分组名称不一致,pass
                        break;
                    }
                }
            }
            if (!curHeaderName.equals(getRealHeaderName(pos + 1)) && groupHeadPos.size() > 0) {
                int preHeadPos = (int) ((TreeSet) (groupHeadPos)).last();
                int padSpan = span - (pos - preHeadPos) % span;
                headerSpanArray.put(pos, padSpan);
            }
        }
    }

    private Set headerPaddingSet = new TreeSet<>();                //用来记录每个头部的paddintTop信息
    private Set groupHeadPos = new TreeSet<>();                    //记录每个分组第一个头部的pos【用于计算当前组最后一个item的span】
    private SparseArray headerSpanArray = new SparseArray<>();     //用来记录每个分组最后一个item的span
    private GridLayoutManager.SpanSizeLookup lookup;

    @Override
    public void getItemOffsets(Rect outRect, View itemView, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, itemView, parent, state);
        if (lookup == null) {
            lookup = new GridLayoutManager.SpanSizeLookup() {//相当于weight
                @Override
                public int getSpanSize(int position) {
                    int returnSpan = 1;
                    int index = headerSpanArray.indexOfKey(position);
                    if (index >= 0) {
                        returnSpan = headerSpanArray.valueAt(headerSpanArray.indexOfKey(position));   //设置itemView PaddingTop的距离
                    }

                    return returnSpan;
                }
            };
            final GridLayoutManager gridLayoutManager = (GridLayoutManager) parent.getLayoutManager();
            gridLayoutManager.setSpanSizeLookup(lookup);
        }


        /*我们为每个不同头部名称的第一个item设置头部高度*/
        int pos = parent.getChildAdapterPosition(itemView); //获取当前itemView的位置
        if (headerPaddingSet.contains(pos)) {
            outRect.top = headerHeight;   //设置itemView PaddingTop的距离
        }
    }

}

总结

至此我们的功能都已经描述结束,做了这个小功能的确收货不少,比较多的耗时在GridDecoration的设计,因为不清楚能够动态的设置Span,一开始是通过设置itemOffsets的paddingRight去计算的,然后还要计算下一个分组的头部,各种问题,所以以后做功能时候先看看有没有api可以操作的,这样来的更方便和容易。最后附上github望小伙伴们多多点赞哈。有建议和意见还望在评论出提出~~

[https://github.com/qdxxxx/StickyHeaderDecoration](https://github.com/qdxxxx/StickyHeaderDecoration"optional title")

你可能感兴趣的:(《自定义view系列》)