ItemDecoration解析(一) getItemOffsets

引言

N年前在简书上写了ItemDecoration系列文章, 写完也没维护了, 最近无意间发现百度搜索getItemOffsets,第一篇就是ItemDecoration解析(一) getItemOffsets ,这篇文章访问量也不小,有1W+的阅读量.本着负责任的原则,避免新入坑的小伙伴被里面的纰漏误导,所以决定重新梳理下.

ItemDecoration介绍

ListView切换到RecyclerView的小伙伴应该深有感受RecyclerView为解耦做出的努力.具体体现是RecyclerView将部分职责放到了五虎将上:RecyclerView.LayoutManager,RecyclerView.Adapter,RecyclerView.ViewHolder,RecyclerView.ItemDecoration,RecyclerView.ItemAnimator.

他们各司其职,有各自的作用,挑一个我觉得最有趣的来说:

RecyclerView.LayoutManager负责Item视图的布局的显示管理,LayoutManager承载了部分ViewGroupmeasurelayout的职责,这也是我们最常用的,Google给我们提供了LinearLayoutManager,GridLayoutManager,StaggeredGridLayoutManager三种默认的LayoutManager.还有个FlexboxLayoutManager可以通过添加额外的依赖引入,具体参见github地址

当然还有我们这篇文章要讲的RecyclerView.ItemDecoration.从类名直译过来是”条目装饰”,Google对它的介绍如下:

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给指定的ItemView添加绘图和布局偏移,这对于在项目中给items间绘制分隔线,突出显示,可视化分组边界是非常有用的.

ItemDecoration是个抽象类,排除被标记为过时的方法后只剩下3个方法

1
2
3
4
5
6
7
8
9
10
11
12
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
onDraw(c, parent);
}
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}

其中getItemOffsets方法就是开发文档中提到的起到layout offset作用: 为每一个item设置偏移,说得更通俗点就是给每一个itemView设置margin.我们可以通过分析源码来证明我们的观点.

源码分析

我们都知道ViewGroup会通过onMeasureonLayout来控制子View的宽高和子View的在父ViewGroup中的位置,RecyclerView也不例外,只不过它将主要逻辑都放到了LayoutManager中,RecylerViewonMeasureonLayout的时候,最终都会调用layoutMangeronLayoutChildren方法.

因此肯定onLayoutChildren既负责计算子View的宽高,也负责控制子View在父ViewGroup中的位置.
由于不同LayoutManageronLayoutChildren的实现不同,为了方便分析,我们直接看LinearLayoutManageronLayoutChildren, 通过层层调用onLayoutChildren->fill->layoutChunk,最终会调用关键方法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
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);

RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
// 关键方法1
measureChildWithMargins(view, 0, 0);
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
// 通过mOrientationHelper确定itemView的left top right bottom
int left, top, right, bottom;
// mOrientation 为 VERTICAL时
if (mOrientation == VERTICAL) {

left = getPaddingLeft();
right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);

if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
bottom = layoutState.mOffset;
top = layoutState.mOffset - result.mConsumed;
} else {
top = layoutState.mOffset;
bottom = layoutState.mOffset + result.mConsumed;
}
} else {
// mOrientation为horizontal时逻辑与VERTICAL差不多, left right 与top bottom 赋值逻辑对调
}
// 关键方法2
layoutDecoratedWithMargins(view, left, top, right, bottom);

}

layoutChunk方法中有两处关键方法:

  1. 关键方法1 measureChildWithMargins
  2. 关键方法2 layoutDecoratedWithMargins

measureChildWithMargins

从方法命名就能大致猜出:measureChildWithMargins负责计算子View宽高,layoutDecoratedWithMargins负责控制子View的位置, 我们逐个击破,先看看measureChildWithMargins方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();

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);
}
}

做过自定义View的同学应该一样就能看出,这里通过父ViewGroup的宽高,父ViewGroupPadding,子ViewMargin,子View在布局文件中的layout_width,layout_height以及getItemDecorInsetsForChild方法获取一个Rect类型的insert,并计算widthUsed,heightUsed来综合确定View的宽高.来个差不多的示意图,这里的insert就类似子View的又一层margin

示意图

我们继续分析getItemDecorInsetsForChild方法来看看这个insert到底是什么东西,

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
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);
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;
}

总算看到ItemDecoration的影子了,是不是有种豁然开朗的感觉,遍历RecyclerViewItemDecoration,通过ItemDecorationgetItemOffsets累加获得一个Rect类型的insets, 并且赋值给子View layoutParamsmDecorInsets字段.

这里有两个重要信息

  1. ItemDecoration是个集合, 最终结果是ItemDecoration累加决定的
  2. 累加得到的insets会赋值给子View layoutParamsmDecorInsets字段,(子View布局的时候会用到)

到这里子View的宽高计算就分析完了
我们小结下

  1. RecyclerViewonMeasure方法负责子View宽高的计算, 但最终由LayouManageronLayoutChildren控制.
  2. 不同的LayouManageronLayoutChildren有不同的实现,但对ItemDecoration的处理都一样.
  3. 对于LinearLayoutMnager,onLayoutChildren最终会调用layoutChunk,里面有计算子View宽高的逻辑
  4. layoutChunk中的measureChildWithMargins负责计算子View的宽高.View的宽高由父ViewGroup的宽高,父ViewGroupPadding,子ViewMargin,子View在布局文件中的layout_width,layout_height以及getItemDecorInsetsForChild方法获取一个Rect类型的insert,综合决定.
  5. RecyclerViewgetItemDecorInsetsForChild方法会遍历ItemDecoration,并调用ItemDecorationgetItemOffset方法,累加得到一个insert,并赋值给子View``LayoutParamsmDecorInsets字段.
  6. insert可以理解为ItemView额外的margin

layoutDecoratedWithMargins

View的宽高计算完了,接下来就是子View在父ViewGroup中的位置计算了,我们来看看关键方法2

1
2
3
4
5
6
7
8
9
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;
child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
right - insets.right - lp.rightMargin,
bottom - insets.bottom - lp.bottomMargin);
}

很简单, 而且我们刚刚说的mDecorInsets字段也参与了计算, 它的作用和layoutParams lp中的margin一样.

篇幅有限, 这里我们省略了left,top,right,bottom赋值的分析, 这里简单说下,
LinearLayoutManager会根据orientation创建mOrientationHelper,mOrientationHelper又会根据orientation实现抽象方法,拿两个方法举下例子:

  1. getDecoratedMeasurement表示子Vieworientation同方向上的大小,orientationvertical时,计算子ViewgetDecoratedMeasuredHeighttopMargin,bottomMargin
  2. getDecoratedMeasurementInOther表示与orientation垂直方向上的大小,orientationvertical时,计算子ViewgetDecoratedMeasuredWidthleftMargin,rightMargin

源码的层级很多,但一步步分析下来,耐着性子看,其实也不复杂, 而且googleAndroid工程师的代码确实有参考的地方,设计模式也随处可见, 比如利用工厂模式创建mOrientationHelper.

总之, ItemDecorationgetItemOffset可理解为给子View再加一层margin.

实践

源码分析完了, 我们再来实践下吧.

LinearLayoutManager

我们先考虑LinearLayoutManager,orientationvertical时的情形:
假设我们现在的需求是RecyclerViewpaddingLeft,paddingRight12dp,paddingTop,paddingBottom20dp, 每个item间的间距是4dp.在不了解ItemDecoration前我们会怎么处理呢.

一般情况下, 我们有两个方案:

  1. 直接设置RecyclerViewpadding,然后设置clipToPadding属性, 最后给itemView设置marginTop或者marginBottom,并在onBindViewHolder时,处理position==0 或者 position==itemCount的特殊item

  2. onBindViewHolder时, 当position==0设置marginTop=20dp , 当position==itemCount设置marginBottom=20dp,其他position设置marginTop=4dp或者marginBottom=4dp

上面两个方案都是可行的, 但也会让ViewHolder变得臃肿, 我们可以利用ItemDecoration来处理,实际上就是把上述逻辑放到ItemDecorationgetItemOffset方法中,然后通过RecyclerViewaddItemDerocation方法添加我们自定义的ItemDecoration就ok了.

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
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val adapter = parent.adapter
val itemCount = adapter?.itemCount ?: 0
val childAdapterPosition = parent.getChildAdapterPosition(view)
if (itemCount > 0) {
when (childAdapterPosition) {
0 -> // 第一个要设置marginTop
outRect.set(
12,
20,
12,
4
)
itemCount - 1 -> // 最后一行要设置marginBottom
outRect.set(
12,
0,
12,
20
)
else -> outRect.set(
12,
0,
12,
4
)
}
}
}

GridLayoutManager

我们再来考虑GridLayoutManager,orientationvertical,span为4时的情形:
假设我们现在的需求是RecyclerViewpaddingLeft,paddingRight12dp,paddingTop,paddingBottom20dp, 每个item间的横向间距horizontalSpace10dp,纵向间距是4dp.

同样我们也可以利用ItemDecoration来处理, 先说下思路:

  1. 第一行的item设置marginTop为20dp
  2. 最后一行的item设置marginBottom为20dp
  3. 其他item设置marginTop,或者marginBottom为4dp
  4. 动态计算每一个item的marginLeftmarginRight,保证marginLeft+width+marginRight相等, 以达到均分的目的.

上面最重要的是第四步骤, 我们再来分析如何计算

因为paddingLeftpaddingRight是12dp, 每个item间横向距离是10dp, 所以每一个item的marginLeft+marginRight 应该是

mEachSpace = ( paddingLeft+paddingRight + (spanCount -1) * horizontalSpace)/4 = 13.5

每行第一列item的 marginLeft = paddingLeft = 12
每行最后一列item的 marginRight= paddingRight = 12, 那么最后一列item的marginLeft = mEachSpace -paddingRight = 1.5.
实际上每个item的marginLeftmarginRight都是等差数列,
marginLeft为例子:
已知A4 = 1.5,A1 = 12, 根据等差数列公式 An = (n - 1) * d + A1 可以算出

d =( A4 - A1) / (4 - 1) = -3.5, 有了 d 我们又可以算出A2= 8.5 ,A3= 5.

那么对应的marginRight Bn也可以根据mEachSpace-An算出:

1
2
3
4
B1 = 13.5 - 12 = 1.5
B2 = 13.5 - 8.5 = 5
B3 = 13.5 - 5 = 8.5
B4 = 13.5 - 1.5 = 12

经过上述分析, 我们可以在getItemOffset里面这样处理,伪代码如下:

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
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val spanCount = 4
val adapter = parent.adapter
val itemCount = adapter?.itemCount ?: 0
val childAdapterPosition = parent.getChildAdapterPosition(view)
if (itemCount > 0 && mOrientationDecorationHelper.needOffset(outRect, view, parent)) {
val mEachSpace = (12 * 2 + (spanCount - 1) * 10) / spanCount
// 一种是vertical
val diff = ((mEachSpace - 12) - 12) / (spanCount - 1) // d = (An-A1)/(n-1)
val column = 根据position计算列数(childAdapterPosition) // 列数 从0开始计数
val left = (column + 1 - 1) * diff + 12 // left = (column-1)*diff+A1
val right = mEachSpace - left
val currentGroup = 根据position计算行数(childAdapterPosition)
val totalGroup = 计算总行数()
// 如果就只有一行
when (currentGroup) {
0 -> // 首行要设置marginTop
outRect.set(
left,
20,
right,
0
)
totalGroup -> // 最后一行要设置marginBottom
outRect.set(
left,
0,
right,
20
)
else ->
outRect.set(
left,
0,
right,
4
)
}
}
}

结语

上面我们通过LinearLayoutManager,GridLayoutManager orientationVERTICAL时候的具体例子, 当orientationHORIZONTAL时, 处理逻辑也差不多, 只需要把left,right赋值与topbottom对调就行. 很像mOrientationHelper中的逻辑,当然还有一些细节是需要处理的.比如

  1. LayoutManagerreverseLayouttrue时候的情形.
  2. 只有一行或者只有一列的情形
  3. GridLayoutManager设置了SpanSizeLookup的情形

这些细节问题大家可以自行处理, 当然对于这些情形的处理,我也为大家写了一个工具类,放个github地址,求start!哈哈!!

作者

sadhu

发布于

2019-06-25

更新于

2021-04-10

许可协议