感谢
上图来自于网络,上图的列表中有一个悬浮的粘性头部的效果,现在这种效果的需求比较常见了,像通讯录,展示城市列表,还有一些咨询类 App 分类时都会见到这种效果。如果用 ListView 来实现,可谓是十分麻烦,而且查了查相关资料并不多,如果用 RecyclerView 的话,实现这种效果简直是分分钟的事。
1 ItemDecoration
要实现这种效果,首先我们需要了解一个 RecyclerView 的内部类 ItemDecoration,之前我在学习 RecyclerView 有记录过,从零开始学习RecyclerView(三),重温一下。
ItemDecoration 是一个抽象类,字面意思是 Item 的装饰,我们可以通过内部的绘制方法绘制装饰,它有三个需要实现的抽象方法(过时的方法不管):
onDraw() :该方法在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之前绘制,也就是说,如果该 Decoration 没有设置偏移的话,Item 的内容会覆盖该 Decoration。
onDrawOver() :在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之后绘制 ,也就是说,如果该 Decoration 没有设置偏移的话,该 Decoration 会覆盖 Item 的内容。
getItemOffsets() :为 Decoration 设置偏移。
首先我们写一个 Decoration,只是一个简单的分隔线效果,分隔线中间有文字:
public class StickyDecoration extends RecyclerView.ItemDecoration {
private int mHeight;
private Paint mPaint;
private TextPaint mTextPaint;
private Rect mTextBounds;
public StickyDecoration() {
mHeight = 100;
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(Color.GRAY);
mTextPaint = new TextPaint();
mTextPaint.setAntiAlias(true);
mTextPaint.setColor(Color.parseColor("#FF000000"));
mTextPaint.setTextSize(48f);
mTextBounds = new Rect();
}
/**
* Description:在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之前绘制
* 也就是说,如果该 Decoration 没有设置偏移的话,Item 的内容会覆盖该 Decoration。
* Date:2018/9/14
*/
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state); //Decoration 的左边位置
}
/**
* Description:在 Canvas 上绘制内容作为 RecyclerView 的 Item 的装饰,会在 Item 绘制之后绘制
* 也就是说,如果该 Decoration 没有设置偏移的话,该 Decoration 会覆盖 Item 的内容。
* Date:2018/9/14
*/
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String stickyHeaderName = "我是分隔线";
int left = parent.getLeft();
//Decoration 的右边位置
int right = parent.getRight();
//获取 RecyclerView 的 Item 数量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//Decoration 的底边位置
int bottom = childView.getTop();
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(stickyHeaderName, 0, stickyHeaderName.length(), mTextBounds);
c.drawText(stickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
/**
* Description:为 Decoration 设置偏移
* Date:2018/9/14
*/
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
//如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
outRect.top = mHeight;
}
}
效果是这样:
现在就将这个分隔线一步一步实现成粘性头部。
2 同一组显示只显示一个分隔线
刚才分隔线中的文字是写死的,所以需要提供给外部一个可以设置每一个分隔线文字的方法,然后在绘制分隔线的时候判断如果绘制的 position 的分割线的文字与上一个 position 的分隔线的文字一样的话就不绘制。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右边位置
int right = parent.getRight();
//获取 RecyclerView 的 Item 数量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position == 0) {
//Decoration 的底边位置
int bottom = childView.getTop();
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底边位置
int bottom = childView.getTop();
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
/**
* Description:为 Decoration 设置偏移
* Date:2018/9/14
*/
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
//如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
int position = parent.getChildAdapterPosition(view);
String stickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(stickyHeaderName)) {
return;
}
if (position == 0) {
outRect.top = mHeight;
return;
}
String previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
outRect.top = mHeight;
}
}
/**
* author:MrQinshou
* Description:提供给外部设置每一个 position 的粘性头部的文字的方法
* date:2018/10/14 22:14
* param
* return
*/
public abstract String getStickyHeaderName(int position);
getItemOffsets() 与 onDrawOver() 中的判断差不多,都是如果当前位置的 stickyHeaderName 为空,则不预留粘性头部空间和不绘制粘性头部,然后如果是第 0 个位置,则直接给空间和绘制,在之后的 position 的 Item,需要拿到上一个 position 的粘性头部的文字与当前 position 的相比,如果不同才绘制。outRect.top 如果不给值的话,默认是 0。下面测试一下:
MainActivity 中就根据填充的数据来设置一下粘性头部的文字:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final List<String> list = getList(120);
RecyclerView rvTest = (RecyclerView) findViewById(R.id.rv_test);
TestAdapter testAdapter = new TestAdapter();
rvTest.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
rvTest.addItemDecoration(new StickyDecoration() {
@Override
public String getStickyHeaderName(int position) {
return list.get(position);
}
});
rvTest.setAdapter(testAdapter);
testAdapter.setDataList(list);
}
private List<String> getList(int size) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 120; i++) {
if (i < size / 3) {
list.add("力量英雄");
} else if (i < size / 3 * 2) {
list.add("敏捷英雄");
} else {
list.add("智力英雄");
}
}
return list;
}
}
效果是这样的:
3 同一组的一直显示
接下来只需要将上面的同一组的头部一直显示在顶端,形成粘性效果,直到下一组的头部滑动上来时,才慢慢替换掉上一个头部,有一个推动效果。这个其实也很简单,因为 RecyclerView 在滑动时一直在回调 onDrawOver() 方法,所以我们该方法中绘制每一个 Item 的粘性头部时不断计算 Decoration 的位置,使其不会随着 Item 的滑动一起往上移动,即一直处于 RecyclerView 顶部的位置。
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右边位置
int right = parent.getRight();
//获取 RecyclerView 的 Item 数量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position == 0 || i == 0) {
//Decoration 的底边位置
int bottom = Math.max(childView.getTop(), mHeight);
//当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
//就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
View nextChildView = parent.getChildAt(i + 1);
String nextStickyHeaderName = getStickyHeaderName(position + 1);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - 1);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底边位置
int bottom = Math.max(childView.getTop(), mHeight);
//当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
//就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
View nextChildView = parent.getChildAt(i + 1);
String nextStickyHeaderName = getStickyHeaderName(position + 1);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
主要是 bottom 的计算,这样就完成了粘性头部的效果,而且下一组不同的头部到来时,也会有一个推动的过渡效果,不会很生硬:
4 GridLayoutManager
上面的粘性头部 Decoration 只适用 LinearLayoutManager,而且是垂直方向的 LinearLayoutManager,不过考虑到一般水平方向的 LayoutManager 对粘性头部的需求很少,所以暂时没去实现它。如果将上面的粘性头部 Decoration 用在 GridLayoutManager 上会怎样呢?
MainActivity 中将 LayoutManager 改为 spanCount 为 4 的 GridLayoutManager:
rvTest.setLayoutManager(new GridLayoutManager(this, 4));
可以看到有分隔线的那一行,除了第一个,其他的都往上”移“了。其实并不是 Item 往上移了,只是在 getItemOffsets() 中只给第一个 Item 设置了偏移,所以我们设置偏移的时候不是只给 position==0 的 Item 设置了,而要给第一行,即 position<spanCount 的 Item 都设置偏移。在其他比较粘性头部的文字是否相等的地方也不是和上一个 Item 比较了,而是和上一行的比较,说穿了也就是 position-spanCount 的那一个比较。
在 StickyDecoration 中给一个变量 spanCount,默认为 1,当外部使用的是 GridLayoutManager 时可以传入 GridLayoutManager 的 spanCount 供我们计算 Decoration 的绘制,修改 onDrawOver() 和 getItemOffsets() 方法:
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
String previousStickyHeaderName = null;
String currentStickyHeaderName = null;
int left = parent.getLeft();
//Decoration 的右边位置
int right = parent.getRight();
//获取 RecyclerView 的 Item 数量
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = parent.getChildAt(i);
//判断上一个 position 粘性头部的文字与当前 position 的粘性头部文字是否相同,如果相同则跳过绘制
int position = parent.getChildAdapterPosition(childView);
currentStickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(currentStickyHeaderName)) {
continue;
}
if (position < mSpanCount || i < mSpanCount) {
//Decoration 的底边位置
int bottom = Math.max(childView.getTop(), mHeight);
//当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
//就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
View nextChildView = parent.getChildAt(i + mSpanCount);
String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
continue;
}
previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
if (!TextUtils.equals(previousStickyHeaderName, currentStickyHeaderName)) {
//Decoration 的底边位置
int bottom = Math.max(childView.getTop(), mHeight);
//当当前 Decoration 的 Bottom 比下一个 View 的 Decoration 的 Top (即下一个 View 的 getTop() - mHeight)大时
//就应该使当前 Decoration 的 Bottom 等于下一个 Decoration 的 Top,形成推动效果
View nextChildView = parent.getChildAt(i + mSpanCount);
String nextStickyHeaderName = getStickyHeaderName(position + mSpanCount);
if (nextChildView != null && !TextUtils.equals(currentStickyHeaderName, nextStickyHeaderName) && bottom > (nextChildView.getTop() - mHeight)) {
bottom = nextChildView.getTop() - mHeight;
}
//Decoration 的顶边位置
int top = bottom - mHeight;
c.drawRect(left, top, right, bottom, mPaint);
//绘制文字
mTextPaint.getTextBounds(currentStickyHeaderName, 0, currentStickyHeaderName.length(), mTextBounds);
c.drawText(currentStickyHeaderName, left, bottom - mHeight / 2 + mTextBounds.height() / 2, mTextPaint);
}
}
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//outRect 相当于 Item 的整体绘制区域,设置 left、top、right、bottom 相当于设置左上右下的内间距
//如设置 outRect.top = 5 则相当于设置 paddingTop 为 5px。
int position = parent.getChildAdapterPosition(view);
String stickyHeaderName = getStickyHeaderName(position);
if (TextUtils.isEmpty(stickyHeaderName)) {
return;
}
if (position < mSpanCount) {
outRect.top = mHeight;
return;
}
String previousStickyHeaderName = getStickyHeaderName(position - mSpanCount);
if (!TextUtils.equals(stickyHeaderName, previousStickyHeaderName)) {
outRect.top = mHeight;
}
}
OK,这样就可以适用于 GridLayoutManager 了,MainActivity 中修改一下测试代码:
rvTest.setLayoutManager(new GridLayoutManager(this, 4));
rvTest.addItemDecoration(new StickyDecoration(4) {
@Override
public String getStickyHeaderName(int position) {
return list.get(position);
}
});
效果如下:
5 遗留问题
这样其实还有一个问题,在 GridLayoutManager 中上面的测试数据都是每一组数据的个数都是 spanCount 的整数倍,如果不是整数倍的时候就会出现分隔线错乱,这个情况暂时还没有想到好的办法解决,如有好的思路还望不吝赐教。