使用RecyclerView替代ListView已经是老生常谈的话题了,RecyclerView的优秀和灵活已经经过了大量项目的实践。最近在完成一个分组列表的需求时,使用到ItemDecoration,故在此对其做一番总结,加深对其的理解。
ItemDecoration介绍
An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.
ItemDecoration允许应用结合adapter的数据集,对特定的item添加绘制一个周边图案。可以用于给items之间添加分割线、高亮装饰效果或者分组边界等等。
从谷歌官方的介绍可以知道,ItemDecoration是用于给列表的item添加各种装饰效果,开发中最常见的就是为item添加分割线。
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);
}
}
从源码注释中,可以大概了解这三个方法的用途:
-
onDraw
:在item绘制之前时被调用,将指定的内容绘制到item view内容之下; -
onDrawOver
:在item被绘制之后调用,将指定的内容绘制到item view内容之上 -
getItemOffsets
:在每次测量item尺寸时被调用,将decoration的尺寸计算到item的尺寸中
ItemDecoration三个方法的测试
谷歌官方在support.v7包中提供了ItemDecoration的一个实现DividerItemDecoration
,这里结合这个实现,来看看其三个需要实现的方法对UI的影响。
onDraw
private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int right;
if (parent.getClipToPadding()) {
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
canvas.clipRect(left, parent.getPaddingTop(), right,
parent.getHeight() - parent.getPaddingBottom());
} else {
left = 0;
right = parent.getWidth();
}
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
parent.getDecoratedBoundsWithMargins(child, mBounds);
final int bottom = mBounds.bottom + Math.round(ViewCompat.getTranslationY(child));
final int top = bottom - mDivider.getIntrinsicHeight();
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}
drawVertical
方法实现了对Orientation == VERTICAL
的RecyclerView绘制item之间的分割线。从传入的canvas参数可以推断,分割线的绘制是通过canvas机制绘制到屏幕上:mDivider.draw(canvas);
其中,mDivider是一个Drawable对象,可以通过setDrawable传入自定义对象,不传入时,会自动使用系统内置的分割线样式:android.R.attr.listDivider
。通过遍历每一个可见的child view,计算mDivider对应的left、top、right、bottom值,从而绘制到正确的位置上。对于纵向的RecyclerView而言,mDivider的left和right是固定的,和parent的左右内容边界保持一致,也就是说,把parent的左右padding都计算进去,因而是代表了RecyclerView实际的内容区域。纵向的分割线一般位于每个item的底部,因此mDivider的top值理论上应该和child view的内容下边界保持贴合。实际上,计算top和bottom的代码,谷歌官方也有所调整,在最新的实现中,先通过parent.getDecoratedBoundsWithMargins(child, mBounds);
拿到之前在onMeasure过程中,通过调用getItemOffsets获取到的mBounds,mBounds是包括了整个child view以及其decoration的总边界,之后再计算mDivider的bottom、top值。
getItemOffsets
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (mOrientation == VERTICAL) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
官方实现的getItemOffsets比较简单,只是根据列表的方向,返回了分割线在相应方向的尺寸。这里可能有一个坑,即通过setDrawable设置自定义的分割线时,容易传入一个无尺寸的drawable对象,导致分割线无法显示出来的bug,典型的代码是这样:
decoration.setDrawable(new ColorDrawable(Color.RED));
DividerItemDecoration的实现中,是没有复写onDrawOver方法的,对于分割线场景而言,也确实不需要去实现它。接下来,通过几个例子,展示一下getItemOffsets对于ItemDecoration在UI上的影响。
getItemOffsets & onDraw
先上动图【注2】:
上图中,getItemOffsets方法里,返回outRect不同,而onDraw方法绘制的分割线高度初始值设为25,并通过外部增减来观察其UI效果。
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 50);// outRect.set(50,50,50,50);
}
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
for (int i = 0; i < childCount; i++) {
final View view = parent.getChildAt(i);
top = view.getBottom();
left = view.getPaddingLeft() + mSize;
right = view.getWidth() - view.getPaddingRight() - mSize ;
bottom = top + mSize;
divider.setBounds(left, top, right, bottom);
divider.draw(c);
}
}
从上面两个动图对比,可以得出以下几个结论:
- getItemOffsets返回的矩形outRect会被计算到child view的尺寸当中;
- onDraw方法绘制的图形,可以超出outRect所规定的区域;
- onDraw方法绘制的图形,确实是处于child view的底下,当两者发生重叠时,只会显示child view的内容;
getItemOffsets & onDrawOver
将之前onDraw方法内代码完整拷贝到onDrawOver下,并注释掉之前onDraw中的方法,很容易验证出onDrawOver与onDraw的唯一不同之处。
- onDrawOver绘制的图形,处于child view之上,当两者发生重叠时,会显示onDrawOver的内容;
ItemDecoration三个方法的含义,就介绍到这里。可以感觉到,三个方法都很简单而基础,可以十分优雅的实现item的分割线效果,然而简单的如DividerItemDecoration,往往是无法满足项目开发需求的。经常会遇到某几个item不想要分割线(如头部或者最后一个item),这就需要开发者自行来实现。
利用ItemDecoration实现分组列表效果
先看效果图:
上图展示了利用ItemDecoration实现分组栏的效果,对于分组效果,需要注意的点在于,如何确定分组栏位置和内容,如何实现分组栏吸顶效果(如果需要)。
- 分组栏位置一般是由外部决定,常见是根据数据源list中某个特征值来决定,比较好的做法是通过接口来实现。
public interface IHover {
/**
* 当前position是否需要绘制分组栏
* @param position 当前位置
* @return true表示需要绘制
*/
boolean isGroup(int position);
/**
* 当前位置需要绘制的文本
* @param position 当前位置
* @return String
*/
String groupText(int position);
}
- 分组栏效果实际上是利用了onDrawOver和onDraw方法,onDraw方法负责绘制每一个需要分组的Decoration,而onDrawOver方法只绘制最顶部item的Decoration,由于onDrawOver绘制的内容永远会显示在最顶层,因此,实际上是,每一个顶部item都绘制了一个Decoration,但是相同分组的Decoration内容和位置一摸一样,就导致看上去是一直吸顶的效果。部分代码如下:
#onDraw:
if (builder.iHover.isGroup(position)) {
bottom = childView.getTop();
top = bottom - builder.decorationHeight;
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
String text = builder.iHover.groupText(position);
if (!TextUtils.isEmpty(text)) {
Paint.FontMetrics fm = textPaint.getFontMetrics();
//文字竖直居中显示
float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
int textLeft = left;
float textWidth = textPaint.measureText(text, 0, text.length());
if (builder.textAlign == Builder.ALIGN_MIDDLE) {
textLeft = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
}
c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
}
}
#getItemOffsets:
// 分组模式只在分组时才绘制
if (builder.iHover.isGroup(pos)) {
outRect.set(0, builder.decorationHeight, 0, 0);
}
#onDrawOver:
// 只有需要分组功能时,才走以下逻辑
if (builder.iHover != null) {
int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
int bottom, top;
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
top = parent.getPaddingTop();
bottom = top + builder.decorationHeight;
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(c);
String text = builder.iHover.groupText(position);
if (!TextUtils.isEmpty(text)) {
Paint.FontMetrics fm = textPaint.getFontMetrics();
//文字竖直居中显示
float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
int textLeft = left;
float textWidth = textPaint.measureText(text, 0, text.length());
if (builder.textAlign == Builder.ALIGN_MIDDLE) {
textLeft = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
}
c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
}
}
简单的封装MKItemDecoration
- 支持简单颜色分割线
- 支持简单颜色分割线 + 文字:文字可以居左、居中
- 支持分割线跳过起始诺干个item,跳过最后一个item
- 支持分组悬停效果
-
支持自定义View作为Decoration
上图hoverGroup.gif的使用代码如下:
recyclerView.addItemDecoration(new MKItemDecoration.Builder()
.height(50)
.color(Color.parseColor("#525D97"))
.textSize(30)
.textColor(Color.WHITE)
.itemOffset(0)
.iHover(new IHover() {
@Override
public boolean isGroup(int position) {
return position % 4 == 0;
}
@Override
public String groupText(int position) {
return adapter.data.get(4 * (position / 4));
}
}).
.textAlign(MKItemDecoration.Builder.ALIGN_MIDDLE)
.build());
通过封装,利用builder模式来更好的自定义需要的Decoration,其中,为了支持自定义View,需要外部传入相关的view的资源id和需要绑定的数据List,控件内部会通过view的measure,layout,draw的流程,将其绘制在屏幕上。
具体代码见:https://github.com/Dragon-Boat/library
欢迎提issue 和 star~
TODO:
- itemDecoration是通过draw绘制图形,不支持点击事件
感谢:
- https://blog.piasy.com/2016/03/26/Insight-Android-RecyclerView-ItemDecoration/
- https://github.com/fishyer/PinnedRecyclerView
注1:图片引用自该文章链接。
注2:动图使用Vysor+GifCam录制,前者将手机屏幕内容投射到电脑上,后者录制git图片。