前言
RecyclerView 计划用两个章节来讲解,今天主要是以 itemDecoration 和 实现吸顶效果为主;
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 的实现原理解析
欢迎三连
来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~