在我们的日常开发中,RecyclerView已经被使用的越来越广泛,今天来讲一讲使用ItemDecoration来实现项目中需要的悬浮头部的效果。我们使用listView就可以知道,直接从xml文件中使用 android:divider 这个属性就可以直接设置listVie中itemw的分割线,可以设置分割线的drawable,但是在recyclerView中却没有这个属性了,有时候为了图方便,直接在RecyclerView的item里面通过设置view的方式设置分割线,Google其实并不推荐这种做法的,因为这样设置了之后,一些notifyItemInsert等这样的效果就失去了,因为,为了更灵活的定制分割线,Google给我们提供了一个类RecyclerView.ItemDecoration,如果想要实现自定义分割线的话需要去继承这个类,然后实现他的几个方法。具体如下:
如果懒的实现分割线,Google给我们提供了一个默认的分割线,DividerItemDecoration(),里面两个参数,一个context,一个是分割线的方向,横向或者纵向DividerItemDecoration.Vertical或者DividerItemDecoration.Horizantal
我们点进源码就可以看到,RecyclerView.ItemDecoration并不复杂,是一个抽象类,并且只有几个方法,主要的方法分别为 getItemOffsets、onDraw、onDrawOver,其余的都是已废弃的,也是相互调用的方法,三个方法如下,
public abstract static class ItemDecoration {
public void onDraw(Canvas c, RecyclerView parent, State state) {
onDraw(c, parent);
}
@Deprecated
public void onDraw(Canvas c, RecyclerView parent) {
}
public void onDrawOver(Canvas c, RecyclerView parent, State state) {
onDrawOver(c, parent);
}
@Deprecated
public void onDrawOver(Canvas c, RecyclerView parent) {
}
@Deprecated
public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
outRect.set(0, 0, 0, 0);
}
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
getItemOffsets方法包含4个参数,其中outRect 若不设置则是一个全为 0 的 Rect。view 指 RecyclerView 中的 Item。parent 就是 RecyclerView 本身,state 就是一个状态。
@Deprecated
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 0);
}
可以看这张图,绿色区域代表 RecyclerView 中的一个 ItemView,而外面橙色区域也就是相应的 outRect,也就是 ItemView 与其它组件的偏移区域,等同于 margin 属性,通过复写 getItemOffsets() 方法,然后指定 outRect 中的 top、left、right、bottom 就可以控制各个方向的间隔了。注意的是这些属性都是偏移量,是指偏移 ItemView 各个方向的数值。当然,这个方法只是设置item的偏移量,具体要设置背景什么的要看下面的几个方法。
我们知道,onDraw()方法是自定义View必不可少的方法,具体就是绘制出自己想要的外观,里面三个参数canvas、recyclerView以及状态,这个方法是配合前面一个 getItemOffsets方法一起绘制的,getItemOffsets 撑开了 ItemView 的上下左右间隔区域,而 onDraw 方法通过计算每个 ItemView 的坐标位置与它的 outRect 值来确定它要绘制内容的区间。需要注意的是,onDraw方法是在绘制每一个itemView之前进行绘制的,如果绘制不当的话,itemView的内容就很可能会覆盖掉我们在onDraw方法里绘制的内容。
假设,我们要设计一个高度为 1 px 的分割线,那么我们就需要在每个 ItemView top位置上方画一个 1 px 高度的矩形,然后填充颜色为红色。 代码也挺简单。
/**
* 针对每一个ItemView设置偏移
* outRect 全为0的一个矩形Rect
* view RecyclerView中的Item
* parent RecyclerView本身
* state Item的状态
*/
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
//设置偏移的高度
outRect.top = 1
}
需要注意的一点是 getItemOffsets 是针对每一个 ItemView,而 onDraw 方法却是针对 RecyclerView 本身,所以在 onDraw 方法中需要遍历屏幕上可见的 ItemView,分别获取它们的位置信息,然后分别的绘制对应的分割线。
/**
* 针对 RecyclerView 本身,需要遍历屏幕上可见的Item
* 在Item之前绘制
* 通过计算每个Item的坐标位置与outRect确定绘制内容的区间
*/
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
}
接下来是onDrawOver方法,可以看到,onDrawOver和onDraw方法差别并不大,方法只是名字不一样而已,区别就是onDraw是绘制在itemView的内容之前,而onDrawOver则是在绘制itemView之后进行绘制,可以覆盖itemView的内容之上,因此,我们可以制造出我们想要的如时光轴效果,但是,我们今天要研究的是实现悬浮头部,就是每个item都有自己的头部,当上移至移出屏幕时,头部依然悬浮在最上方,常见的就是微信联系人那种效果了。
/**
* Item之后绘制
* 当前的item
* 1、不是屏幕上第一个可见的Item,但是是组内第一个Item,此时需要绘制
* 2、不是屏幕上第一个可见的Item,而且不是组内第一个Item,此时不需要绘制
* 3、是屏幕上第一个可见的Item,需要绘制,位置固定
*/
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
}
接下来看看如何实现,首先,新建一个类继承RecyclerView.ItemDecoration,然后,依然在我们的getItemOffsets方法里面为我们要绘制的头部设置合适的区间,如果想绘制文字,要先测量出文字的高度,然后再设置outRect的top值,具体初始化的代码如下,
init {
mPaint.color = Color.YELLOW
mPaint.isDither = true
mTvPaint.color = Color.RED
mTvPaint.isDither = true
mTvPaint.textSize = TypedValue.applyDimension(COMPLEX_UNIT_SP,12f,context.resources.displayMetrics)
val rect = Rect()
mTvPaint.getTextBounds("王",0,1,rect)
fontMetricsInt = mTvPaint.fontMetricsInt
mTvHeight = rect.height()
Log.e(TAG,"文字高度-> $mTvHeight")
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
//设置偏移的高度为文字的高度
outRect.top = mTvHeight
}
紧接着,在onDraw方法里,绘制出想要的文字及背景,代码如下,这里只是简单的绘制,需要注意的是,由于android坐标系的原因,我们在drawRect的时候,top的值应该为view.top-tvHeight,因为向下为正,然后对我们想要绘制的效果进行分析:
当当前的itemView为屏幕上第一个可见的 ItemView,此时需要绘制,而且该起始位置应该依附在 RecyclerView 的内容起始位置,因为只有这样才会表现出悬浮的效果。因此,我们可以对代码进行这样编写,注释写的比较清楚了
/**
* Item之后绘制
* 当前的item
* 1、不是屏幕上第一个可见的Item,但是是组内第一个Item,此时需要绘制
* 2、不是屏幕上第一个可见的Item,而且不是组内第一个Item,此时不需要绘制
* 3、是屏幕上第一个可见的Item,需要绘制,位置固定
*/
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
val childCount = parent.childCount
for(i in 0 until childCount){
var view = parent.getChildAt(i)
var index = parent.getChildAdapterPosition(view)
var left = parent.paddingLeft.toFloat()
var right = (parent.width - parent.paddingRight).toFloat()
//不是屏幕上第一个可见的Item
if (i!=0){
var top = (view.top - mTvHeight).toFloat()
var bottom = view.top.toFloat()
//
drawHeader(view.top,c,left,top,right,bottom)
}else{
// 屏幕上第一个可见的Item 此时因为要悬浮,所以要以recyclerView的顶部为准,而不是item了,位置要注意下
var top = parent.paddingTop
var sugTop = view.bottom - mTvHeight
// 当 ItemView 与 Header 底部平齐的时候,判断 Header 的顶部是否小于
// parent 顶部内容开始的位置,如果小于则对 Header.top 进行位置更新,
//否则将继续保持吸附在 parent 的顶部
if (sugTop<=top){
top = sugTop
}
var bottom = top + mTvHeight
c.drawRect(left, top.toFloat(), right, bottom.toFloat(),mPaint)
//之前写的是 bottom/2 改成 top + mTvHeight/2
val baselineY = bottom/2 +(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom
Log.e(TAG,"onDrawOver-> $left,$top,$right,$bottom")
Log.e(TAG,"onDrawOver基线-> $baselineY")
c.drawText("王", left, baselineY.toFloat(),mTvPaint)
}
}
}
这里有几个点需要注意:
1.由于我们这里只是简单的对每一个item都设置的header,因为在判断的时候只判断了位置为0和不为0。不为0的时候按照正常的情况进行绘制,为0的时候此时我们就要绘制在recyclerView的顶部,此时的位置应该以recycerView为准,区别就是:
var top = parent.paddingTop
然后这里的baseline应该是val baselineY = bottom/2 +(fontMetricsInt.bottom-fontMetricsInt.top)/2-fontMetricsInt.bottom
因为文字的center就直接是bottom/2,即(top+mTvHeight)/2
2.在实现该效果之后,运行发现有一点小bug,发现顶上去的效果不是很理想,是因为文字的高度没有计算正确,因为之前考虑的文字的中心位置是bottom/2,在加入吸顶的代码之后,因为我们修改了top的值,所以会引起一些小的误差,在这里文字中心位置修改为top+mTvHeight/2,然后测试就可以了