引言
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
承载了部分ViewGroup
中measure
和layout
的职责,这也是我们最常用的,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个方法
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
会通过onMeasure
和onLayout
来控制子View
的宽高和子View
的在父ViewGroup
中的位置,RecyclerView
也不例外,只不过它将主要逻辑都放到了LayoutManager
中,RecylerView
在onMeasure
和onLayout
的时候,最终都会调用layoutManger
的onLayoutChildren
方法.
因此肯定onLayoutChildren
既负责计算子View
的宽高,也负责控制子View
在父ViewGroup
中的位置.
由于不同LayoutManager
对onLayoutChildren
的实现不同,为了方便分析,我们直接看LinearLayoutManager
的onLayoutChildren
, 通过层层调用onLayoutChildren
->fill
->layoutChunk
,最终会调用关键方法layoutChunk
,精简源码如下:
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
measureChildWithMargins
- 关键方法2
layoutDecoratedWithMargins
measureChildWithMargins
从方法命名就能大致猜出:measureChildWithMargins
负责计算子View
宽高,layoutDecoratedWithMargins
负责控制子View
的位置, 我们逐个击破,先看看measureChildWithMargins
方法
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
的宽高,父ViewGroup
的Padding
,子View
的Margin
,子View
在布局文件中的layout_width
,layout_height
以及getItemDecorInsetsForChild
方法获取一个Rect
类型的insert
,并计算widthUsed
,heightUsed
来综合确定View
的宽高.来个差不多的示意图,这里的insert
就类似子View
的又一层margin
我们继续分析getItemDecorInsetsForChild
方法来看看这个insert
到底是什么东西,
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
的影子了,是不是有种豁然开朗的感觉,遍历RecyclerView
的ItemDecoration
,通过ItemDecoration
的getItemOffsets
累加获得一个Rect
类型的insets
, 并且赋值给子View
layoutParams
的mDecorInsets
字段.
这里有两个重要信息
ItemDecoration
是个集合, 最终结果是ItemDecoration
累加决定的- 累加得到的
insets
会赋值给子View
layoutParams
的mDecorInsets
字段,(子View
布局的时候会用到)
到这里子View
的宽高计算就分析完了
我们小结下
RecyclerView
的onMeasure
方法负责子View
宽高的计算, 但最终由LayouManager
的onLayoutChildren
控制.- 不同的
LayouManager
对onLayoutChildren
有不同的实现,但对ItemDecoration
的处理都一样. - 对于
LinearLayoutMnager
,onLayoutChildren
最终会调用layoutChunk
,里面有计算子View
宽高的逻辑 layoutChunk
中的measureChildWithMargins
负责计算子View
的宽高.View
的宽高由父ViewGroup
的宽高,父ViewGroup
的Padding
,子View
的Margin
,子View
在布局文件中的layout_width
,layout_height
以及getItemDecorInsetsForChild
方法获取一个Rect
类型的insert
,综合决定.RecyclerView
的getItemDecorInsetsForChild
方法会遍历ItemDecoration
,并调用ItemDecoration
的getItemOffset
方法,累加得到一个insert
,并赋值给子View``LayoutParams
的mDecorInsets
字段.insert
可以理解为ItemView
额外的margin
layoutDecoratedWithMargins
子View
的宽高计算完了,接下来就是子View
在父ViewGroup
中的位置计算了,我们来看看关键方法2
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
实现抽象方法,拿两个方法举下例子:
getDecoratedMeasurement
表示子View
与orientation
同方向上的大小,orientation
为vertical
时,计算子View
的getDecoratedMeasuredHeight
加topMargin
,bottomMargin
getDecoratedMeasurementInOther
表示与orientation
垂直方向上的大小,orientation
为vertical
时,计算子View
的getDecoratedMeasuredWidth
加leftMargin
,rightMargin
源码的层级很多,但一步步分析下来,耐着性子看,其实也不复杂, 而且google
的Android
工程师的代码确实有参考的地方,设计模式也随处可见, 比如利用工厂模式创建mOrientationHelper
.
总之, ItemDecoration
的getItemOffset
可理解为给子View
再加一层margin
.
实践
源码分析完了, 我们再来实践下吧.
LinearLayoutManager
我们先考虑LinearLayoutManager
,orientation
为vertical
时的情形:
假设我们现在的需求是RecyclerView
的paddingLeft
,paddingRight
为12dp
,paddingTop
,paddingBottom
是20dp
, 每个item间的间距是4dp
.在不了解ItemDecoration
前我们会怎么处理呢.
一般情况下, 我们有两个方案:
-
直接设置
RecyclerView
的padding
,然后设置clipToPadding
属性, 最后给itemView
设置marginTop
或者marginBottom
,并在onBindViewHolder
时,处理position==0
或者position==itemCount
的特殊item
-
在
onBindViewHolder
时, 当position==0
设置marginTop=20dp
, 当position==itemCount
设置marginBottom=20dp
,其他position
设置marginTop=4dp
或者marginBottom=4dp
上面两个方案都是可行的, 但也会让ViewHolder
变得臃肿, 我们可以利用ItemDecoration
来处理,实际上就是把上述逻辑放到ItemDecoration
的getItemOffset
方法中,然后通过RecyclerView
的addItemDerocation
方法添加我们自定义的ItemDecoration
就ok了.
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
,orientation
为vertical
,span
为4时的情形:
假设我们现在的需求是RecyclerView
的paddingLeft
,paddingRight
为12dp
,paddingTop
,paddingBottom
是20dp
, 每个item间的横向间距horizontalSpace
是10dp
,纵向间距是4dp
.
同样我们也可以利用ItemDecoration
来处理, 先说下思路:
- 第一行的item设置
marginTop
为20dp - 最后一行的item设置
marginBottom
为20dp - 其他item设置
marginTop
,或者marginBottom
为4dp - 动态计算每一个item的
marginLeft
和marginRight
,保证marginLeft
+width
+marginRight
相等, 以达到均分的目的.
上面最重要的是第四步骤, 我们再来分析如何计算
因为paddingLeft
和paddingRight
是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的marginLeft
和marginRight
都是等差数列,
以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
算出:
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
里面这样处理,伪代码如下:
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
orientation
为VERTICAL
时候的具体例子, 当orientation
为HORIZONTAL
时, 处理逻辑也差不多, 只需要把left
,right
赋值与top
和bottom
对调就行. 很像mOrientationHelper
中的逻辑,当然还有一些细节是需要处理的.比如
LayoutManager
中reverseLayout
为true
时候的情形.- 只有一行或者只有一列的情形
GridLayoutManager
设置了SpanSizeLookup
的情形
这些细节问题大家可以自行处理, 当然对于这些情形的处理,我也为大家写了一个工具类,放个github地址,求start!哈哈!!
本文由 sadhu 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为:
2020/03/12 11:03