关于RecyclerView的ItemView装饰,之前一直用官方Demo的DividerItemDecoration
,并没有认真地去理解ItemDecoration
的用法,也没能体会到ItemDecoration
的强大,直到要用到横向的RecyclerView,而且最左边的和最右边的Item要留出间隔(虽然clip结合padding可以实现),才认真地理解一下ItemDecoration
。
RecyclerView
可以多次调用addItemDecoration(ItemDecoration decor)
或addItemDecoration(ItemDecoration decor, int index)
方法有序地为RecyclerView添加ItemDecoration,ItemDecoration会影响每一个ItemView的测量和绘制。
先看一下不加ItemDecoration时的RecyclerView:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.frank.lollipopdemo.MainActivity">
<android.support.v7.widget.RecyclerView
android:id="@+id/rv"
android:layout_width="100px"
android:layout_height="500px"
android:background="#CCCCCC" />
RelativeLayout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="100px"
android:layout_height="100px"
android:background="#99FFFF00"
android:gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_logo"
android:layout_width="50px"
android:layout_height="50px" />
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"/>
LinearLayout>
为了方便,这里尺寸都使用px,建议使用dp。
RecyclerView宽100px高500px,背景为灰色#CCCCCC。每个ItemView宽100px高100px,背景为黄色#99FFFF00。
ItemDecoration
是RecyclerView
的静态内部类,用来向ItemView绘制一些装饰以及调整ItemView的偏移。它有只有三个方法:
getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
onDraw(Canvas c, RecyclerView parent, State state)
onDrawOver(Canvas c, RecyclerView parent, State state)
getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
方法,直观一点说,就是用来设置ItemView的inset(内嵌偏移)的,类似于InsetDrawable
,可以看成在ItemView的外面包裹一层偏移。
我们先让每个ItemView下面空出50px来:
public class ItemDecor extends RecyclerView.ItemDecoration {
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 50);
}
}
public class ItemDecor extends RecyclerView.ItemDecoration {
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(50, 50, 50, 50);
}
}
可以看到,我们通过设置outRect的left, top, right, bottom属性值就可以让ItemView产生相应的偏移(内嵌),那RecyclerView是怎么根据outRect的这四个属性值设置ItemView的inset的呢?
RecyclerView
是一个自定义的ViewGroup
,每个ItemView都是它的child,那它又是怎样通过LayoutManager测量并布局ItemView的呢?
看一下LayoutManager
的measureChildWithMargins
方法(measureChild
方法与之类似,只是没有ItemView的margin而已):
/**
* 使用标准测量策略测量ItemView,
* 把RecyclerView的padding、所有已经添加的ItemDecoration尺寸、ItemView的margin都算在内
*
* 如果RecyclerView可以在两个维度滚动,那么调用者可能会传0给widthUsed和heightUsed
*
* @param child 要测量的子view(ItemView)
* @param widthUsed 已经被其它ItemDecoration占用的宽度(px)
* @param heightUsed 已经被其它ItemDecoration占用的高度(px)
*/
public void measureChildWithMargins(View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
// 累加当前ItemDecoration宽高值
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
getPaddingLeft() + getPaddingRight() +
lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
canScrollHorizontally());
final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
getPaddingTop() + getPaddingBottom() +
lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
canScrollVertically());
if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
child.measure(widthSpec, heightSpec);
}
}
可以看到测量child时,需要调用RecyclerView
的getItemDecorInsetsForChild(View child)
方法获得ItemDecoration的inset:
Rect getItemDecorInsetsForChild(View child) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
// 如果不是dirty数据,直接返回
if (!lp.mInsetsDirty) {
return lp.mDecorInsets;
}
// 如果是dirty数据,重新计算
final Rect insets = lp.mDecorInsets;
// 重置inset
insets.set(0, 0, 0, 0);
//将inset设置为所有已添加的ItemDecoration的getItemOffsets的累加(因为RecyclerView可能添加了多个ItemDecoration)
final int decorCount = mItemDecorations.size();
for (int i = 0; i < decorCount; i++) {
mTempRect.set(0, 0, 0, 0);
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;
}
第15行,调用了ItemDecoration
的getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
方法,所以我们在getItemOffsets()
方法中对outRect的设置会被当做ItemView的inset进行测量,inset就像padding和margin一样,会影响view的尺寸和位置。
好了,我们大概知道了我们对outRect的设置是怎样对ItemView产生影响的(RecyclerView和LayoutManager共同协调测量ItemView的逻辑有点复杂,有时间要认真看一下),接下来,我们就可以利用outRect随便调整ItemView的位置,就像我们平时自定义ViewGroup时写onLayout()
方法layout子View一样,是不是很灵活啊。
然后就是绘制ItemDecoration了。onDraw()
的绘制会先于ItemView的绘制,所以如果你在onDraw()
方法中绘制的东西在ItemView边界内,就会被ItemView盖住。而onDrawOver()
会在ItemView绘制之后再绘制,所以如果你在onDrawOver()
方法中绘制的东西在ItemView边界内,就会盖住ItemView。简单点说,就是先执行ItemDecoration的onDraw()
、再执行ItemView的onDraw()
、再执行ItemDecoration的onDrawOver()
。由于和RecyclerView使用的是同一个Canvas,所以你想在Canvas上画什么都可以,就像我们平时自定义View时写onDraw()
方法一样。
我们先在RecyclerView上边画一个圆形:
public class ItemDecor extends RecyclerView.ItemDecoration {
Paint mPaint;
public ItemDecor() {
mPaint = new Paint();
mPaint.setColor(0x99FF0000);
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
c.drawCircle(50, 30, 30, mPaint);
}
}
只需要在Canvas中你想要绘制的位置绘制你想画的东西就行了,什么矩形、渐变、Bitmap等等,没有做不到只有你想不到。
那我想绘制分隔线,怎么知道每个ItemView的位置呢?很简单,遍历一下RecyclerView的child就行了:
public class ItemDecor extends RecyclerView.ItemDecoration {
Paint mPaint;
public ItemDecor() {
mPaint = new Paint();
mPaint.setColor(0x99FF0000);
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top = child.getBottom() + params.bottomMargin +
Math.round(ViewCompat.getTranslationY(child));
final int bottom = top + 50;
c.drawRect(left, top, right, bottom, mPaint);
}
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 0, 0, 50);
}
}
如果想让第一个ItemView之前也有一个红色分割线怎么办?也很简单,先给第一个ItemView设置insetTop和insetBottom内嵌,其它的ItemView只设置insetBottom内嵌:
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int itemPosition = parent.getChildAdapterPosition(view);
int dataSize = parent.getAdapter().getItemCount();
if (itemPosition == 0) {
outRect.set(0, 50, 0, 50);
} else {
outRect.set(0, 0, 0, 50);
}
}
然后绘制的时候,在第一个ItemView的insetTop区域再绘制一个分割线就行了:
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
final int left = parent.getPaddingLeft();
final int right = parent.getWidth() - parent.getPaddingRight();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top1 = child.getTop() - params.bottomMargin -
Math.round(ViewCompat.getTranslationY(child)) - 50;
final int bottom1 = top1 + 50;
final int top2 = child.getBottom() + params.bottomMargin +
Math.round(ViewCompat.getTranslationY(child));
final int bottom2 = top2 + 50;
c.drawRect(left, top2, right, bottom2, mPaint);
if (i == 0) {
c.drawRect(left, top1, right, bottom1, mPaint);
}
}
}
public class ItemDecor extends RecyclerView.ItemDecoration {
Paint mPaint;
public ItemDecor() {
mPaint = new Paint();
mPaint.setColor(0x99FF0000);
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
final int left = parent.getPaddingLeft();
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
.getLayoutParams();
final int top = child.getTop() - params.bottomMargin -
Math.round(ViewCompat.getTranslationY(child)) - 30;
mPaint.setTextSize(20f);
final int adapterPosition = parent.getChildAdapterPosition(child);
c.drawText("item:" + adapterPosition, left + 5, top + 20, mPaint);
}
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
outRect.set(0, 30, 0, 0);
}
}
public class SkillRatingDistributionItemDecor extends RecyclerView.ItemDecoration {
private Paint mPaint;
private Paint mValuePaint;
private PathEffect mDashPathEffect;
public SkillRatingDistributionItemDecor() {
mPaint = new Paint();
mValuePaint = new Paint();
mDashPathEffect = new DashPathEffect(new float[]{5, 5, 5, 5}, 1);
mPaint.setAntiAlias(true);
mValuePaint.setAntiAlias(true);
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
final int rv_left = parent.getLeft();
final int rv_top = parent.getTop();
final int rv_right = parent.getRight();
final int rv_bottom = parent.getHeight();
final int rv_top_line = rv_top + 42;
final int rv_bottom_line = rv_bottom - 20;
final int y_spacing = (int) ((rv_bottom_line - rv_top_line) / 4f);
for (int i = 0; i < 5; i++) {
if (i == 0 || i == 4) {
mPaint.setPathEffect(null);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0xFFE1E6EB);
mPaint.setStrokeWidth(2f);
} else {
mPaint.setPathEffect(null);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0x7FE1E6EB);
mPaint.setStrokeWidth(1f);
}
c.drawLine(rv_left, rv_top_line + i * y_spacing, rv_right, rv_top_line + i * y_spacing, mPaint);
}
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
SkillRatingDistributionObj skillRatingDistributionObj = (SkillRatingDistributionObj) child.getTag();
if (skillRatingDistributionObj != null) {
int skill_rating = Integer.parseInt(skillRatingDistributionObj.getSkill_rating());
if (skill_rating % 25 != 0 && skill_rating != 1) {
continue;
}
final int child_left = child.getLeft();
mValuePaint.setColor(0xFF000000);
mValuePaint.setTextSize(18f);
mValuePaint.setTextAlign(Paint.Align.CENTER);
c.drawText(skillRatingDistributionObj.getSkill_rating(), child_left + 8, 30f, mValuePaint);
mPaint.setPathEffect(mDashPathEffect);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setColor(0xFFE1E6EB);
mPaint.setStrokeWidth(2f);
c.drawLine(child_left + 8, rv_top_line, child_left + 8, rv_bottom_line, mPaint);
}
}
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
int itemPosition = parent.getChildAdapterPosition(view);
int dataSize = parent.getAdapter().getItemCount();
if (itemPosition == 0) {
outRect.set(20, 42, 0, 22);
} else if (itemPosition == dataSize - 1) {
outRect.set(0, 42, 20, 22);
} else {
outRect.set(0, 42, 0, 22);
}
}
}
我们可以通过outRect控制ItemView上下左右的“偏移”,可以通过onDraw随便往RecyclerView/ItemView中画东西,简直不能再灵活啊。RecyclerView类由于大量的静态内部类,代码行数一万多行了,和LayoutManager类的交互也很复杂,有时间研究一下代码可以学到很多东西。
文章中的代码已经很完整了,如果想直接看一下demo,可以clone一下我在Git上的Demo。
ListView:
如果ListView高度为wrap_content,那么无论Item总高度多少,都不会在底部添加分隔线。
如果ListView高度为match_parent或固定高度,那么当Item总高度小于ListView高度时会添加底部分隔线,否则不会添加底部的分割线。
android:headerDividersEnabled
和android:footerDividersEnabled
只能决定ListView的HeaderView和FooterView分隔线是否绘制(默认为true绘制),并不能消除分隔线导致的Item偏移,即HeaderView/FooterView底部分隔线的空间始终存在,如果设置为false只是不会绘制分割线样式而已(每一个HeaderView/FooterView其实都是一个ItemView)。
Thanks:
Piasy' blog