此效果由 RecyclerView.ItemDecoration 实现
RecyclerView.ItemDecoration此类包含三个方法
onDraw(Canvas c, RecyclerView parent, State state)
为divider设置绘制范围,并绘制到canvas上。绘制范围可以超出getItemOffsets设置的范围,但会绘制在item的下面 onDrawOver(Canvas c, RecyclerView parent, State state)
getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
outRect设置4个方向的值,将被计算进所有decoration的尺寸中,而这个尺寸会被计入RecyclerView的每个item的padding中 执行顺序:decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver
实现逻辑
调用getItemOffsets给每个item设置偏移量,根据位置(或者其他)设置不同偏移量,留出操作空间 调用onDraw方法,在其不同的偏移范围作出不同操作 onDrawOver绘制在最上层,顶上去的逻辑在这搞
getItemOffsets代码我是这么写的
/**
* 每个item的偏移量,在顶部留出空间
*/
override fun getItemOffsets(
outRect: Rect, // 绘画的工具类,画一个长方形
view: View, // 当前的item
parent: RecyclerView,
state: RecyclerView.State
) {
super.getItemOffsets(outRect, view, parent, state)
if (parent.adapter is StarAdapter) {
var adapter : StarAdapter = parent.adapter as StarAdapter
var position: Int = parent.getChildLayoutPosition(view)
if (adapter.isGroupHead(position)) {
// 如果是头部,留更多空间
outRect.set(0, groupHeaderHeight, 0, 0)
} else {
// 如果不是头部,留一条线的空间
outRect.set(0, 4, 0, 0)
}
}
}
然鹅,为啥调用了outRect.set()方法就能使item偏移了呢,咋实现的?
先看看它是在哪调用的,它在RecyclerView中唯一被调用的地方就是 getItemDecorInsetsForChild(View child) 函数,代码如下
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);
// 遍历,在此把每个item的偏移量放进 mTempRect
mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
// 把每个item的偏移量放进 inserts 中
insets.left += mTempRect.left;
insets.top += mTempRect.top;
insets.right += mTempRect.right;
insets.bottom += mTempRect.bottom;
}
lp.mInsetsDirty = false;
return insets;
}
看到这有点蒙,遍历把所有的item偏移量放进了 insets ,也就是说insets记录了所有ItemDecoration所需尺寸的总和,继续深入
在 RecyclerView 的 measureChild(View child, int widthUsed, int heightUsed) 函数中,调用了 getItemDecorInsetsForChild,并把它算在了 child view 的 padding 中
/**
* Measure a child view using standard measurement policy, taking the padding
* of the parent RecyclerView and any added item decorations into account.
*
* <p>If the RecyclerView can be scrolled in either dimension the caller may
* pass 0 as the widthUsed or heightUsed parameters as they will be irrelevant.</p>
*
* @param child Child view to measure
* @param widthUsed Width in pixels currently consumed by other views, if relevant
* @param heightUsed Height in pixels currently consumed by other views, if relevant
*/
public void measureChild(@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() + widthUsed, lp.width,
canScrollHorizontally());
final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
getPaddingTop() + getPaddingBottom() + heightUsed, lp.height,
canScrollVertically());
if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
child.measure(widthSpec, heightSpec);
}
}
上面这段代码中调用 getChildMeasureSpec 函数的第三个参数就是 child view 的 padding,而这个参数就把 insets 的值算进去了。那么现在就可以确认了,getItemOffsets 中为 outRect 设置的4个方向的值,将被计算进所有 decoration 的尺寸中,而这个尺寸,被计入了 RecyclerView 每个 item view 的 padding 中。我们操作的空间也正在这个padding中
空间偏移出来后咱们开始画,如果是一组的开头咱们给它写点东西,如果不是则画条红色的线
override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDraw(c, parent, state)
// parent.getAdapter() instanceof StarAdapter ===> parent.adapter is StarAdapter
if (parent.adapter is StarAdapter) {
var adapter :StarAdapter = parent.adapter as StarAdapter
// 当前屏幕 item 个数
var count: Int = parent.childCount
var left: Float = parent.paddingLeft.toFloat()
var right: Float = (parent.width - parent.paddingRight).toFloat()
for (i in 0 until count) {
// 获取对应i的View
var view: View = parent.getChildAt(i)
// 获取View的布局位置
var position: Int = parent.getChildLayoutPosition(view)
// 是否是头部
var isHeader:Boolean = adapter.isGroupHead(position)
// 要考虑 xml 中设置的 padding;如果是一组的第一个则在偏移空间写入内容
if (isHeader && view.top - groupHeaderHeight - parent.paddingTop >= 0) {
// 画出头部长方形区域
c.drawRect(
left, (view.top - groupHeaderHeight).toFloat(),
right, view.bottom.toFloat(), headPaint)
// 写入的内容
var groupName: String = adapter.getGroupName(position)
// textRect,获得文字的宽高
textPaint.getTextBounds(groupName, 0, groupName.length, textRect)
c.drawText(groupName, left + 20,
(view.top - groupHeaderHeight / 2 + textRect.height() / 2).toFloat(),
textPaint
)
} else {
// 分割线
c.drawRect(left, (view.top-4).toFloat(),
right, view.top.toFloat(),headPaint)
}
}
}
}
最后则是吸顶效果实现,onDrawOver画的内容在最上层
一组的最上层没到来之前显示上一组的,来了则换下一组的 头部顶上去咋实现?根据界面第一个item的top位置,设置顶上吸附模块的高度与内容位置
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(c, parent, state)
if (parent.adapter is StarAdapter) {
var adapter: StarAdapter = parent.adapter as StarAdapter
// 返回可见区域第一个item
// 返回可见区域内的第一个item的position
var position:Int =(parent.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
// 获取对应position的View
val itemView = parent.findViewHolderForAdapterPosition(position)!!.itemView
var left: Float = parent.paddingLeft.toFloat()
var right: Float = (parent.width - parent.paddingRight).toFloat()
var top: Float = parent.paddingTop.toFloat()
// 此处应判别可见区域第二个是否是一组的开始
var isHeader: Boolean = adapter.isGroupHead(position + 1)
var groupName: String = adapter.getGroupName(position)
if (isHeader) {
// 可见区域第一个item在界面留下的空间也就是被顶模块的空间
// 随着可见区域第一个item上移,吸顶模块的高度逐渐压缩
var bottom: Int = min(groupHeaderHeight, itemView.bottom - parent.paddingTop)
c.drawRect(left, top, right, top + bottom, headPaint)
textPaint.getTextBounds(groupName, 0, groupName.length, textRect)
c.drawText(groupName, left + 20,
top + bottom - groupHeaderHeight / 2 + textRect.height() / 2,
textPaint)
} else {
// 如果不是一组的开始,则保持吸顶
c.drawRect(left, top, right, top + groupHeaderHeight,
headPaint)
val groupName = adapter.getGroupName(position)
textPaint.getTextBounds(groupName, 0, groupName.length, textRect)
c.drawText(groupName, left + 20,
top + groupHeaderHeight / 2 + textRect.height() / 2, textPaint)
}
}
}
效果实现