0%

RecyclerView的ItemDecoration分析与实际使用

起因

在项目开发中遇到了一些实际的需求,为了满足这些需求不得不去了解新的知识点或者加深对已知知识点的认识,现在就总结一下在实际开发中对RecyclerView的ItemDecoration的使用

ItemDecoration的原理

1.类的方法简介

这个类是RecyclerView的一个静态内部类,正如它的名字,它可以用来对RV的item做一些item之外装饰,它只有定义了三个方法,如下:

  • getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent)
    这个方法用来设置RV的每个item的上下左右的间距,设置的值保存在outRect这个类中
  • public void onDraw(Canvas c, RecyclerView parent)
    绘制方法,Canvas就是RV的Canvas,在调用RV的子View绘制之前被调用,所以在绘制子View之前绘制
  • public void onDrawOver( Canvas c, RecyclerView parent)
    和onDraw方法一样,只不过是在绘制完子View之后才被调用,所以可能会绘制在子View视图之上

ItemDecoration只有这三个方法,和普通的View绘制一样,它是依托于RecyclerView绘制子View的绘制周期来实现方法描述的这些功能的,接着就来看看这三个方法被调用的时机

2.调用getItemOffsets()

以LinearLayoutManager为例,首先是测量,LinearLayoutManager布局子View时会调用layoutChunk方法,其中的measureChildWithMargins测量子View时会调用getItemDecorInsetsForChild方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
//==RV中==
void layoutChunk(){
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
。。。。
}

public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
//累加所有的ItemDecoration的设置的offset
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
//计算widthSpec和heightSpec,getPaddingLeft() + getPaddingRight()
// + lp.leftMargin + lp.rightMargin + widthUsed用来确定在子View在AT——MOST
//模式下的最大宽高
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);
}
}

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);
//获得保存在Rect中的上下左右的offset
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;
}

getItemDecorInsetsForChild方法将mItemDecorations中设置的间距累加并这个值保存在了RecyclerView.LayoutParams的mDecorInsets中

3.RecyclerView布局子View

前面在测量时将ItemDecoration设置的间距保存在了RecyclerView.LayoutParams的mDecorInsets中,在布局的时候就会用这个值来计算布局的偏移。layoutChunk方法中测量完了紧接着就是布局,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

//假如竖直方向布局
void layoutChunk(){
。。。
measureChildWithMargins(view, 0, 0);
//getDecoratedMeasurement返回就是竖直整个子View包括ItemDecoration的所占用的高度
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
int left, top, right, bottom;
if (mOrientation == VERTICAL) {
//左边界,只是RV的padding
left = getPaddingLeft();
//右边界包括子view的宽度、左右margin以及RV.LayoutParams的mDecorInsets
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
//上边界就是整体的偏移
top = layoutState.mOffset;
//下边界包括子View的高度、上下margin以及RV.LayoutParams的mDecorInsets
bottom = layoutState.mOffset + result.mConsumed;

} else {
。。。
}
// We calculate everything with View's bounding box (which includes decor and margins)
// To calculate correct layout position, we subtract margins.
layoutDecoratedWithMargins(view, left, top, right, bottom);
}

public void layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
int bottom)
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final Rect insets = lp.mDecorInsets;
//真正布局确定子View的上下左右边界时去除了margin和mDecorInsets
child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
right - insets.right - lp.rightMargin,
bottom - insets.bottom - lp.bottomMargin);
}

假设LinearLayoutManager竖直布局,在计算这个View需要多大的空间时是把View的margin和ItemDecoration设置的偏移全都算进去了的,但在真正布局子View的时候却去除了子View的margin和mDecorInsets,这样就预留了多的空间出来了。

4.onDraw() 和 onDrawOver()的调用

测量、布局都已经完成,现在就剩绘制了,哪是怎样控制这个先后顺序的呢?
一般情况下ViewGroup的draw方法都是不会被调用的,但在给RV添加ItemDecoration的时候,会调用setWillNotDraw(false)来开启ViewGroup的绘制,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

public void addItemDecoration(ItemDecoration decor, int index) {

if (mItemDecorations.isEmpty()) {
//开启ViewGroup的绘制方法
setWillNotDraw(false);
}
。。。
}

@Override
public void draw(Canvas c) {
//第一步
super.draw(c);
//第三步
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}


@Override
public void onDraw(Canvas c) {
//第二步
super.onDraw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}

RV的draw方法首先是调用了super.draw(),ViewGroup的绘制会先调用自己的onDraw方法之后就把绘制事件分发给了子View,最后才回到draw方法继续执行,所以ItemDecoration是利用了ViewGroup调用绘制方法的先后顺序来达到目的的。

接下来就是实际的开发中需求

实现重叠的子View

项目中要求RV的图片与图片之间有重叠的部份,在点击某张图片的时候将图片完全显示出来,效果如下

4_rv_item.gif

ItemDecoration可以设置间距,但不影响子View的大小,间距为正数叫间距,间距为负数就是重叠,所以可以把Rect的top设置为负数就可以实现子View的重叠了。但还有一个问题,绘制都是从第一个子View开始挨个绘制的,要让点击的子View全部显示出来就需要改变绘制子View的顺序,将点击的View最后绘制,ViewGroup有一个getChildDrawingOrder,这个方法可以设置子View的绘制顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* Returns the index of the child to draw for this iteration. Override this
* if you want to change the drawing order of children. By default, it
* returns i.
* <p>
* NOTE: In order for this method to be called, you must enable child ordering
* first by calling {@link #setChildrenDrawingOrderEnabled(boolean)}.
*
* @param i The current iteration.
* @return The index of the child to draw this iteration.
*
* @see #setChildrenDrawingOrderEnabled(boolean)
* @see #isChildrenDrawingOrderEnabled()
*/
protected int getChildDrawingOrder(int childCount, int i) {
return i;
}

在RV中不用去复写getChildDrawingOrder方法,它提供了RecyclerView.ChildDrawingOrderCallback,可以通过设置这个Callback来改变子View的绘制顺序,最后大概的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
rv.setChildDrawingOrderCallback(new RecyclerView.ChildDrawingOrderCallback() {
@Override
public int onGetChildDrawingOrder(int childCount, int i) {
View v = rv.getFocusedChild();

int focusIndex = rv.indexOfChild(v);
if (focusIndex == RecyclerView.NO_POSITION) {
return i;
}
// supposely 0 1 2 3 4 5 6 7 8 9, 4 is the center item
// drawing order is 0 1 2 3 9 8 7 6 5 4
if (i < focusIndex) {
return i;
} else if (i < childCount - 1) {
return focusIndex + childCount - 1 - i;
} else {
return focusIndex;
}
}
});

rv.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
outRect.top = -200

}
});

实现子View的间距不一样

比如需求要求第一排的子View需要预留300px的空白空间用于显示后面的海报,其他子View不变,但它们的类型一样,一种解决方法就是设置ItemDecoration,把第一排的子view的top offset设置的大一些

1
2
3
4
5
6
7
8
9
10
11
12
rv.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
//第一排的子View设置top值
int pos = parent.getChildLayoutPosition(view);
if (pos < GRID_COLUMN_COUNT) {
outRect.top = 50;

}

}

关于ItemDecoration的总结就完了,最后看代码时还发现用来拖拽子View的ItemTouchHelper居然是继承自ItemDecoration,大概就是在onDraw方法中去改变子View的x,y坐标来实现的。