RecyclerView 不同组别标题的吸顶效果图如下:
该效果是通过自定义 ItemDecoration 实现的,看代码之前先来看一下 ItemDecoration 的工作原理。
一、ItemDecoration 简介
1.ItemDecoration 源码
RecyclerView 的抽象静态内部类 ItemDecoration 负责管理 RecyclerView 中各个 ItemView 的装饰(如分隔线、高亮显示、可视化分组边界):
/**
* ItemDecoration 允许向 Adapter 中的数据集中的特定项视图添加特殊的绘图和布局
* 偏移量,这对于在 Item 之间绘制分隔线、高亮显示、可视化分组边界等都很有用。
* 所有的 ItemDecoration 都按照添加的顺序绘制,其中 onDraw() 是在 ItemView
* 之前绘制,onDrawOver() 是在 ItemView 之后绘制。
*/
public abstract static class ItemDecoration {
/**
* onDraw 会先于 ItemView 绘制,如果二者绘制的内容由重叠区域,
* onDraw 绘制的内容会被 ItemView 的内容覆盖。
*/
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
onDraw(c, parent);
}
@Deprecated
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent) {
}
/**
* 在 ItemView 之后进行绘制,绘制内容在 ItemView 上面。
*/
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
@Deprecated
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent) {
}
@Deprecated
public void getItemOffsets(@NonNull Rect outRect, int itemPosition,
@NonNull RecyclerView parent) {
outRect.set(0, 0, 0, 0);
}
/**
* 检索给定 Item 的偏移量,单位是px。outRect 的每个字段都指定了 Item
* View 应该被嵌入的像素值,类似于 padding 或 margin。默认实现是将
* outRect 的边界设为0。
* 如果这个 ItemDecoration 不影响 ItemView 的位置,需要将 outRect 的四个
* 字段设置为0。
*
* 如果需要获取 Adapter 中的额外数据,可以调用通过
* RecyclerView#getChildAdapterPosition(View) 来获取 Adapter 的位置。
*/
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
ItemDecoration 有两个需要注意的地方:
- getItemOffsets() 中的 outRect 用来指定绘制 ItemDecoration 的预留空间。
- ItemDecoration 与 RecyclerView 中的 ItemView 的绘制顺序:ItemDecoration#onDraw() -> ItemView#onDraw() -> ItemDecoration#onDrawOver(),先执行绘制的方法,其绘制内容在较下层。
getItemOffsets() 与 outRect
其实 RecyclerView 中的每个 ItemView 外都有一个 outRect 用以指定在 left、top、right 和 bottom 四个方向上的预留空间(下图灰色区域):
源码中,在 RecyclerView 的 getItemDecorInsetsForChild() 中会对每个 ItemView 的 outRect 占用空间进行计算:
// 由于每个 ItemView 可以有多个 ItemDecoration 修饰,所以把它们存在列表中
final ArrayList<ItemDecoration> mItemDecorations = new ArrayList<>();
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;
}
// 计算结果也用一个 Rect 表示,初始化为(0, 0, 0, 0)
final Rect insets = lp.mDecorInsets;
insets.set(0, 0, 0, 0);
final int decorCount = mItemDecorations.size();
// 对当前 child 上所有的 ItemDecoration 进行四个方向上的累加计算
for (int i = 0; i < decorCount; i++) {
mTempRect.set(0, 0, 0, 0);
// 调用 getItemOffsets() 获取 outRect 信息
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;
}
计算的结果在 RecyclerView 对每个 Item 进行测量时会用到:
public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
// 获取 child 的 outRect 的测量结果,并分别在宽度和高度上累加
final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
widthUsed += insets.left + insets.right;
heightUsed += insets.top + insets.bottom;
// RecyclerView 的左右 padding,以及 child 的左右 margin 再加上包括
// 所有 outRect 在水平方向上占用的空间之和,作为已经被使用的空间,在
// 计算 child 的 MeasureSpec 时会从总宽度中减掉。
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);
}
}
也就是说,getItemOffsets() 中由 outRect 指定的每一个 Item 的预留空间,都会在 RecyclerView 进行测量时,被计算在内。
onDraw() 与 onDrawOver()
当 RecyclerView 进行绘制时,它的父 ViewGroup 的 dispatchDraw() 会调用到 drawChild() 开始绘制子控件:
#ViewGroup
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
return child.draw(canvas, this, drawingTime);
}
这样就来到了 RecyclerView 当中:
#RecyclerView
@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);
}
……
}
先 super 去到父类的 draw():
#View
// 不是本文重点,省略绝大部分代码
public void draw(Canvas canvas) {
/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content
* 4. Draw children
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance)
* 7. If necessary, draw the default focus highlight
*/
// Step 3, draw the content
onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
}
执行到 Step3 就会调用到 RecyclerView 的 onDraw():
#RecyclerView
@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);
}
}
在这里,先调用了 ItemDecorations 的 onDraw()。然后回到 View 中的 Step4,又会执行到 ViewGroup 的 dispatchDraw(),这一次再分发绘制事件,其实就是 RecyclerView 分发去绘制各个 ItemView 的,这是个递归过程,不再赘述。只需知道,经过这一步,ItemView 都绘制完成了。
代码再向上返回,RecyclerView draw() 的 super.draw() 执行完了,接着就会再遍历 mItemDecorations 执行 ItemDecoration 的 onDrawOver() 了。
由此可见,绘制的先后顺序就是 ItemDecoration#onDraw() -> ItemView#onDraw() -> ItemDecoration#onDrawOver(),先绘制的显示在下层,后绘制的在上层,如果绘制出现了重叠的部分,当然就是在上层的优先显示啦:
到这里 ItemDecoration 中两个重要的知识就说完了,这两点也是我们实现 RecyclerView 标题吸顶的关键。
2.DividerItemDecoration
我们再看看系统是如何实现 ItemDecoration 的。系统提供了一个 DividerItemDecoration 用来绘制 Item 之间的分隔线。首先在 getItemOffsets() 中会在绘制方向上预留出一个分割线占用的空间大小:
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (mDivider == null) {
outRect.set(0, 0, 0, 0);
return;
}
if (mOrientation == VERTICAL) {
// 竖直方向布局时,在底部留出一个 mDivider 的高度
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
// 水平方向布局时,在右侧留出一个 mDivider 的宽度
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
再根据 RecyclerView.LayoutManager 中指定的方向(竖直 or 水平),绘制分隔线:
// 分隔线,用 Drawable 画
private Drawable mDivider;
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getLayoutManager() == null || mDivider == null) {
return;
}
// mOrientation 方向由 RecyclerView.LayoutManager 指定
if (mOrientation == VERTICAL) {
drawVertical(c, parent);
} else {
drawHorizontal(c, parent);
}
}
private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int right;
// 先看 RecyclerView 中的布尔成员 mClipToPadding,为 true 表示则表示
// View 不能在 padding 区域内进行绘制。可以通过布局中的
// android:clipToPadding 属性进行设置,默认为 true。
if (parent.getClipToPadding()) {
// 对 canvas 进行剪裁,边界将 padding 刨除在外
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
canvas.clipRect(left, parent.getPaddingTop(), right,
parent.getHeight() - parent.getPaddingBottom());
} else {
// 如果可以在 padding 区域内绘制,那么绘制区域不变。
left = 0;
right = parent.getWidth();
}
// 计算出每一个 child 的分隔线的位置,并画出分隔线
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
// 获取装饰边界 mBounds
parent.getDecoratedBoundsWithMargins(child, mBounds);
final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
final int top = bottom - mDivider.getIntrinsicHeight();
// 设置分隔线的位置,其实是画一个矩形
mDivider.setBounds(left, top, right, bottom);
mDivider.draw(canvas);
}
canvas.restore();
}
思路就是先用 parent.getClipToPadding() 确定绘制区域。该方法的意思是确定,是否要将绘制的 canvas 根据父容器的 padding 进行剪裁。如果该方法返回 true,意味着父布局不允许你在 padding 内进行绘制,那么就要将 canvas 减去相应的 padding。
确定好绘制范围后,再计算出每个 ItemView 要绘制内容的位置。以 DividerItemDecoration 为例,获取到装饰边界的 mBounds 后,先确定底部坐标 bottom,再用 bottom 减去分隔线高度得到 top,left 与 right 前面已经确定好了,这样用来绘制分隔线的矩形的四边就确定了,最后调用 Drawable 的绘制方法即可。
二、实现 RecyclerView 标题吸顶效果
先说一下准备工作,RecyclerView 要显示的数据做成 JavaBean:
public class Data {
private String name;
private String groupName; // 分组名称
// getters and setters...
}
RecyclerView 的 Adapter 中要提供一个根据位置判断当前 Item 是否为分组的第一个元素:
public class DataAdapter extends RecyclerView.Adapter<DataAdapter.DataViewHolder> {
private Context context;
private List<Data> list;
public DataAdapter(Context context, List<Data> list) {
this.context = context;
this.list = list;
}
public boolean isGroupHeader(int position) {
if (position == 0) {
return true;
}
// 如果当前位置的 GroupName 和上一个位置的 GroupName 不同即为 GroupHeader
String currentGroupName = getGroupName(position);
String previousGroupName = getGroupName(position - 1);
return !currentGroupName.equals(previousGroupName);
}
public String getGroupName(int position) {
return list.get(position).getGroupName();
}
// ViewHolder 就是一个 TextView 显示 Data 的 name 字段的,省略...
}
下面就开始实现自定义的 ItemDecoration。先重写 getItemOffsets(),给每个 Item 的预留空间设置好:
public class DataItemDecoration extends RecyclerView.ItemDecoration {
private static final int DIVIDER_HEIGHT_IN_PX = 1;
private int groupHeaderHeight;
private Paint backgroundPaint;
private Paint textPaint;
public DataItemDecoration(Context context) {
groupHeaderHeight = dp2px(context, 60);
// 头部背景画笔
backgroundPaint = new Paint();
backgroundPaint.setColor(Color.RED);
// 头部文字画笔
textPaint = new Paint();
textPaint.setTextSize(50);
textPaint.setColor(Color.WHITE);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
if (parent.getAdapter() instanceof DataAdapter) {
DataAdapter adapter = (DataAdapter) parent.getAdapter();
int position = parent.getChildLayoutPosition(view);
if (adapter.isGroupHeader(position)) {
// 如果是头部,就在上方预留出 Header 的高度
outRect.set(0, groupHeaderHeight, 0, 0);
} else {
// 如果不是头部,则在上方留出1px画分隔线
outRect.set(0, DIVIDER_HEIGHT_IN_PX, 0, 0);
}
}
}
}
查看效果发现在每个分组的第一个 Item 上方会出现一个白色区域,即是 outRect:
在 onDraw() 中,不论是在 outRect 中绘制,还是在其余 Item 上方绘制分隔线时,都需要计算绘制区域的边界。以图中 outRect 为例,它的 bottom 就是 Item00 的 top,在已知 outRect 高度为 groupHeaderHeight 的情况下,outRect 的 top 就是 ItemView00.getTop() - groupHeaderHeight。代码如下:
/**
* 所有 GroupHeader 的内容都使用 onDraw() 绘制。
*/
@Override
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(canvas, parent, state);
if (parent.getAdapter() instanceof DataAdapter) {
DataAdapter adapter = (DataAdapter) parent.getAdapter();
// 获取当前屏幕可见的 Item 数量
int childCount = parent.getChildCount();
// 确定 canvas 的左右边界,刨除 padding
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
for (int i = 0; i < childCount; i++) {
View child = parent.getChildAt(i);
int position = parent.getChildLayoutPosition(child);
if (adapter.isGroupHeader(position)) {
// 绘制 GroupHeader 的背景
canvas.drawRect(left, child.getTop() - groupHeaderHeight, right, child.getTop(), backgroundPaint);
String groupName = adapter.getGroupName(position);
// 绘制 GroupHeader 的文字
canvas.drawText(groupName, left + 20, getTextBaselineY(textPaint, child.getTop(), groupHeaderHeight), textPaint);
} else {
// 不是 GroupHeader 就画一个分隔线
canvas.drawRect(left, child.getTop() - DIVIDER_HEIGHT_IN_PX, right, child.getTop(), backgroundPaint);
}
}
}
}
顶置的 GroupHeader 需要用 onDrawOver() 绘制(因为要显示在最上方,覆盖 ItemView 和 onDraw() 绘制的 GroupHeader),并且要实现一个被新来的 GroupHeader 向上推的效果:
这种效果其实只需要让顶置的 GroupHeader 的 bottom 不断变小就能做到,关键问题是什么时候开始变?变化的数值如何计算?
我们的思路是(有个前提,GroupHeader 的高度与 ItemView 的高度是一样的,在例子中都被设置为60dp):
- 先找到 RecyclerView 中第一个可见 Item 在 Adapter 中的位置。
- 在上面提到的前提条件下,第二个可见的 Item 如果是一个 GroupHeader,就要开始改变顶置的 GroupHeader 的 bottom 值了,否则就仍是在固定位置画顶置的 GroupHeader。
- 顶置的 GroupHeader 的 bottom 变化应该跟随第一个可见 Item 的可见部分高度变化(向上滑动的过程中,第一个可见 Item 的可见部分高度逐渐变小,让 GroupHeader 的 bottom 也跟随它变化即可)。
代码如下:
/**
* 吸顶的 GroupHeader 用 onDrawOver() 绘制,就会遮挡 ItemView 和
* 用 onDraw() 绘制的 GroupHeader
*/
@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(canvas, parent, state);
if (parent.getAdapter() instanceof DataAdapter) {
DataAdapter adapter = (DataAdapter) parent.getAdapter();
// 获取 RecyclerView 中第一个可见 Item 的位置以及对应的 View
int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
View itemView = parent.findViewHolderForAdapterPosition(position).itemView;
String groupName = adapter.getGroupName(position);
// 计算边界,其中 top 应该是 RecyclerView 的 paddingTop
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
int top = parent.getPaddingTop();
// 此时的临界条件是:第二个可见的 Item 为 GroupHeader
boolean isGroupHeader = adapter.isGroupHeader(position + 1);
// 计算两种情况下,顶置的 GroupHeader 的 bottom 以及文字 BaseLine 的 Y 轴坐标
int bottom;
int baseLineY;
if (isGroupHeader) {
// 到达临界条件,顶置 GroupHeader 的 top 不变,但是 bottom 要动态计算
int firstItemVisibleHeight = Math.min(groupHeaderHeight, itemView.getBottom());
bottom = firstItemVisibleHeight + top;
baseLineY = getTextBaselineY(textPaint, bottom, groupHeaderHeight);
} else {
// 没到临界条件就按照固定位置画
bottom = top + groupHeaderHeight;
baseLineY = getTextBaselineY(textPaint, top + groupHeaderHeight, groupHeaderHeight);
}
canvas.drawRect(left, top, right, bottom, backgroundPaint);
canvas.drawText(groupName, left + 20, baseLineY, textPaint);
}
}
到这基本上算实现了这个功能,但是有一个 bug,我们在布局中给 RecyclerView 加一个 paddingTop 属性,再来看效果:
可以看到主要有两个问题:
- 白色背景的 paddingTop 部分不应该被绘制。
- 顶置的 GroupHeader 被向上推的效果没有了。
问题1,多余的绘制部分是由 onDraw() 绘制的,看代码发现,在绘制时并没有对绘制区域的合法性进行检查,应该加上:
@Override
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(canvas, parent, state);
if (parent.getAdapter() instanceof DataAdapter) {
DataAdapter adapter = (DataAdapter) parent.getAdapter();
int childCount = parent.getChildCount();
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
for (int i = 0; i < childCount; i++) {
View child = parent.getChildAt(i);
int position = parent.getChildLayoutPosition(child);
// 新增代码,先判断是否会在 RecyclerView 的 paddingTop 区域内
// 绘制,如果是就跳过该 child 的绘制过程。
int minTop = Math.min(child.getTop() - groupHeaderHeight, child.getTop() - DIVIDER_HEIGHT_IN_PX);
if (minTop < parent.getPaddingTop()) {
continue;
}
if (adapter.isGroupHeader(position)) {
canvas.drawRect(left, child.getTop() - groupHeaderHeight, right, child.getTop(), backgroundPaint);
String groupName = adapter.getGroupName(position);
canvas.drawText(groupName, left + 20, getTextBaselineY(textPaint, child.getTop(), groupHeaderHeight), textPaint);
} else {
canvas.drawRect(left, child.getTop() - DIVIDER_HEIGHT_IN_PX, right, child.getTop(), backgroundPaint);
}
}
}
}
问题2的原因是在给 RecyclerView 增加了 paddingTop 后,第一个可见 Item 的可见高度在计算时也应该相应的减掉 paddingTop 的数值:
@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(canvas, parent, state);
if (parent.getAdapter() instanceof DataAdapter) {
DataAdapter adapter = (DataAdapter) parent.getAdapter();
int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
View itemView = parent.findViewHolderForAdapterPosition(position).itemView;
String groupName = adapter.getGroupName(position);
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
int top = parent.getPaddingTop();
boolean isGroupHeader = adapter.isGroupHeader(position + 1);
int bottom;
int baseLineY;
if (isGroupHeader) {
int firstItemVisibleHeight = Math.min(groupHeaderHeight, itemView.getBottom() - parent.getPaddingTop()); // 更新计算方式
bottom = firstItemVisibleHeight + top;
baseLineY = getTextBaselineY(textPaint, bottom, groupHeaderHeight);
} else {
bottom = top + groupHeaderHeight;
baseLineY = getTextBaselineY(textPaint, top + groupHeaderHeight, groupHeaderHeight);
}
canvas.drawRect(left, top, right, bottom, backgroundPaint);
canvas.drawText(groupName, left + 20, baseLineY, textPaint);
}
}
更改后的效果图: