RecyclerView 计划用两个章节来讲解,今天主要是以 itemDecoration 和 实现吸顶效果为主;
ItemDecoration 允许应用给具体的 View 添加具体的图画或者 Layout 的偏移,对于绘制 View 之间的分割线,视觉分组边界等等是非常有用;
当我们调用 RecyclerView 的 addItemDecoration 添加 decoration 的时候,RecyclerView 就会调用该类的 onDraw 方法去绘制分割线,也就是说分割线是绘制出来的;
RecyclerView.ItemDecoration 类是抽象类;
public abstract static class ItemDecoration {
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) {
}
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);
}
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
Android 官方只提供了一个实现类 DividerItemDecoration;我们先来简单看下它是如何实现的;
根据注释我们可以知道,DividerItemDecoration 需要结合 LinearLayoutManager 一起使用,以及它是如何创建并和 RecycerlView 如何绑定的;
我们进入构造方法看下:
可以看到,分割线其实就是一个 Drawable,我们也可以通过 setDrawable 方法自定义一个 Drawable 来定义我们的分割线;
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (parent.getLayoutManager() == null || mDivider == null) {
return;
}
// 绘制的时候,根据方向,绘制不同的分割线
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;
//noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
if (parent.getClipToPadding()) {
left = parent.getPaddingLeft();
right = parent.getWidth() - parent.getPaddingRight();
canvas.clipRect(left, parent.getPaddingTop(), right,
parent.getHeight() - parent.getPaddingBottom());
} else {
left = 0;
right = parent.getWidth();
}
final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
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() 为 true 的话,RecyclerView 的 padding 区域是可以绘制分割线的,否则就可以绘制;用来获取 left 和 right;
然后获取 bottom 和 top,最后调用 Drawable 的 darw 方法,进行绘制;
drawHorizontal 方法,大家可以自行看下;我们来看下 getItemOffsets 方法
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) {
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
这个 outRect 就是在 item 的四周留出指定的间隙;可以看下面这张图来加深下理解:
ItemDecoration 提供了 onDraw 和 onDrawOver 方法,这两个方法有什么区别呢?
本质上来说,onDraw 方法的绘制区域,可能会被 item 遮挡,onDrawOver 的绘制区域不会被 item 遮挡;
onDraw 的绘制区域:
onDrawOver 的绘制区域,它会在 itemview 绘制之后才进行绘制:
接下来,我们来一步一步撸码实现吸顶效果,我们先来搭一个简单的架子:
public class NBAStarDecoration extends RecyclerView.ItemDecoration {
NBAStarDecoration(Context context) {
}
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(c, parent, state);
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
}
}
我们如果想实现吸顶效果,那么就需要判断是不是头部,如果是头部,预留出吸顶的 View 空间,那么如何判断是不是头部呢?我们可以根据每组数据的组名来判断;
public class NBAStar {
private String name;
private String groupName;
public NBAStar(String name, String groupName) {
this.name = name;
this.groupName = groupName;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
}
然后我们在 adapter 中根据 position 来判断当前位置是不是 groupName;
public class NBAStarAdapter extends RecyclerView.Adapter<NBAStarAdapter.NBAStarHolder> {
private Context context;
private List<NBAStar> starList;
public NBAStarAdapter(Context context, List<NBAStar> starList) {
this.context = context;
this.starList = starList;
}
// 根据 position 来判断 当前 groupName 与前一个是不是相等;
public boolean isGroupHeader(int position) {
if (position == 0) {
return true;
} else {
String currentGroupName = getGroupName(position);
String preGroupName = getGroupName(position - 1);
if (TextUtils.equals(currentGroupName, preGroupName)) {
return false;
} else {
return true;
}
}
}
public String getGroupName(int position) {
return starList.get(position).getGroupName();
}
//
...
// 省略部分代码
}
NBAStarDecoration 中根据这个判断来预留对应的空间
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
RecyclerView.Adapter adapter = parent.getAdapter();
if (adapter instanceof NBAStarAdapter) {
NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;
int position = parent.getChildLayoutPosition(view);
boolean groupHeader = nbaStarAdapter.isGroupHeader(position);
if (groupHeader) {
outRect.set(0, dp2px(100),0, 0);
} else {
outRect.set(0, 1, 0 , 0);
}
}
}
我们运行看下效果:
我们给头部预留出来了指定的位置;
接下里我们来绘制头部区域,绘制背景色和头部区域中的文字;
绘制背景色:
public void onDraw(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(canvas, parent, state);
RecyclerView.Adapter adapter = parent.getAdapter();
if (adapter instanceof NBAStarAdapter) {
NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;
int childCount = parent.getChildCount();
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
for (int i = 0; i < childCount; i++) {
View view = parent.getChildAt(i);
int position = parent.getChildLayoutPosition(view);
boolean groupHeader = nbaStarAdapter.isGroupHeader(position);
if (groupHeader) {
canvas.drawRect(new Rect(left, view.getTop() - dp2px(50), right, view.getTop()), paint);
}
}
}
}
我们运行看下效果:
可以看到,我们把头部颜色绘制了出来,接下来我们绘制头部的文字,也就是组名
String groupName = nbaStarAdapter.getGroupName(position);
textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
// 文字中心绘制,上一层有讲解
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
float centerHeight = view.getTop() - dp2px(50) / 2 + ((fontMetrics.descent - fontMetrics.ascent)/2) - fontMetrics.descent;
canvas.drawText(groupName, left + 20, centerHeight, textPaint);
文字中心绘制的原理可以查看上一章,如何应对Android面试官->文字中心绘制和颜色渐变,实战头条炫酷ViewPager指示器
可以看到,文字绘制了出来,但是还没达到我们想要的吸顶效果;
接下里我们来实现吸顶效果:
我们想要实现吸顶效果,那么我们需要在 onDrawOver 中实现,因为它的绘制是在 ItemView 之后,并且是固定的位置;我们需要拿到可见区域的第一个 item 的位置,并判断它是不是头部,以及当我们滑动的时候,当第二个头部的 top 滑动到第一个头部的 bottom 的位置的时候,第一个头部的 bottom 要缩小(移除屏幕),那么 bottom 什么时机开始变小呢?就是在我第一个头部的最后一个 itemView 的 getBottom 小于第一个头部的 bottom 的时候开始变小,具体实现如下:
@Override
public void onDrawOver(@NonNull Canvas canvas, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(canvas, parent, state);
RecyclerView.Adapter adapter = parent.getAdapter();
if (adapter instanceof NBAStarAdapter) {
NBAStarAdapter nbaStarAdapter = (NBAStarAdapter) adapter;
// 获取屏幕可见的第一个 View 的位置
int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
// 获取这个位置的 itemView
View view = parent.findViewHolderForLayoutPosition(position).itemView;
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
int top = parent.getPaddingTop();
boolean groupHeader = nbaStarAdapter.isGroupHeader(position + 1);
if (groupHeader) {
// 判断是不是 itemView 的高度比 头部的高度小
int bottom = Math.min(dp2px(50), view.getBottom());
// 区域绘制
canvas.drawRect(left, top, right, bottom + top, paint);
// 文字绘制
String groupName = nbaStarAdapter.getGroupName(position);
textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
// textRect.height()/2 可以替换成根据 fontMetrics 计算的高度,这里简约下
canvas.drawText(groupName, left + 20, top - dp2px(50) / 2 + textRect.height() / 2, textPaint);
} else {
// 区域绘制
canvas.drawRect(left, top, right, top + dp2px(50), paint);
// 文字绘制
String groupName = nbaStarAdapter.getGroupName(position);
textPaint.getTextBounds(groupName, 0 , groupName.length(), textRect);
// textRect.height()/2 可以替换成根据 fontMetrics 计算的高度,这里简约下
canvas.drawText(groupName, left + 20, top + dp2px(50) / 2 + textRect.height() / 2 , textPaint);
}
}
}
我们运行看下效果:
我们实现了吸顶效果,但是在滑动到顶部的时候,文字的滑出是有问题的,我们接着来看下;
说明我们的高度计算的不太对,应该是 top + bottom - 头部高度/2 + textRect.height()/2;
我们替换看下效果:
canvas.drawText(groupName, left + 20,
top + bottom - dp2px(50) / 2 + textRect.height() / 2, textPaint);
可以看到,没有问题了;
通常我们在使用 RecyclerView 的时候,可能会直接在 RecyclerView 上设置各种 margin 或者 padding,如果接下来,如果我们给 RecyclerView 设置一个 padding 的话,可能会有什么效果?
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/colorPrimary"
android:paddingTop="150dp"/>
</RelativeLayout>
我们运行看下效果:
我们发现,顶部的 padding 区域不符合我们的预期,那么我们应该如何处理呢?
可以看到,这个 padding 内的内容,应该是 onDraw 绘制的头部区域,它应该消失掉,但是并没有,为什么不是 onDrawOver,因为 onDrawOver 绘制的区域是固定不动的;
也就是说我们需要在 onDraw 中处理,那么怎么处理呢?我们需要获取这段 padding 的距离并和 view 的 getTop 以及 头部的高度 之差做对比
也就是:
view.getTop() - dip2px(50) - parent.getPaddingTop() >= 0
所以,onDraw 中的判断规则改为:
if (groupHeader && view.getTop() - dp2px(50) - parent.getPaddingTop() >= 0) {}
我们运行看下效果:
达到了我们期望的效果,但是还有一个问题,就是 现役球星0 文字没有被推上去,我们需要在 onDrawOver 中处理一下,文字没有推上去,是 【现役球星0】的 bottom 没有改变导致的,也就是我们需要改变 bottom 的高度才行;
int bottom = Math.min(dp2px(50), view.getBottom() - parent.getPaddingTop());
我们需要减去这个 paddingTop 的值,我们运行看下效果:
可以看到,文字被推了上去,但是,文字推的优点过多了,进入了 padding 区域,我们需要 drawText 的时候限制绘制的区域才行,也就是我们需要减去这个? paddingTop 的距离 修改如下:
if (top + bottom - dp2px(50) / 2 - parent.getPaddingTop() >= 0) {
canvas.drawText(groupName, left + 20, top + bottom - dp2px(50) / 2 + textRect.height() / 2, textPaint);
}
运行看下效果:
文字可以被推上去了,并且也没有绘制到 paddingTop 区域,至此,我们的吸顶效果实现到这吧~~
深度理解 ItemDecoration 实现原理,可自定义 ItemDecoration 的实现;
RecyclerView 的缓存复用原理和 LayoutManager 的实现原理解析
来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~