我的CSDN: ListerCi
我的: 东方未曦
写在前面
本系列博客的demo都上传到了github:RecyclerViewDemo
如果有帮助到你的话不妨给我点个star~
在介绍ItemDecoration之前我们不妨先看下它能实现什么功能。
这是一个国内大部分城市的列表,通过城市拼音对其排序,通过拼音首字母对其分组。在滑动到某一组的城市时,它的Header会在顶部保持不动,下一组滑动上来时,新的Header会把上一组“顶”上去,这个效果就是ItemDecoration实现的。当然,为了功能的完整性,我还添加了侧边栏用于搜索查找。
看完效果后是不是对ItemDecoration充满了兴趣?现在让我们一步一步去认识它并实现这个功能吧。
一、ItemDecoration简介
1.1 API介绍
ItemDecoration是定义在RecyclerView内部的抽象类,排除过时的方法,它提供了3个可重写的方法,代码如下。
public abstract static class ItemDecoration {
/**
* 绘制提供给RecyclerView的装饰
* 任何由此方法绘制的内容都会在item绘制之前就绘制完毕,因此它是处于item下层的
*/
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull State state) {
onDraw(c, parent);
}
/**
* 此方法与onDraw相对,方法中的内容是在item绘制完毕后开始绘制的
* 因此会显示在item上层
*/
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent,
@NonNull State state) {
onDrawOver(c, parent);
}
/**
* 通过outRect表示当前item距离left、top、right、bottom 4个方向的距离
*/
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
@NonNull RecyclerView parent, @NonNull State state) {
getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
parent);
}
}
其中onDraw(...)
和onDrawOver(...)
方法的参数中传入了画布Canvas,通过画布我们可以在任何坐标绘制任何事物。
getItemOffsets(...)
方法用于指定每个item距离左上右下4个方向的距离,效果同margin。参数中传入的view表示当前的item,如果你想根据item的数据设置不同margin的话,可以通过RecyclerView的getChildAdapterPosition(View)
得到该item在Adapter中的position。
1.2 DividerItemDecoration分析
ItemDecoration最简单的用法就是添加分隔线,如果使用DividerItemDecoration,之后你会发现item之间多了一根细细的分隔线。
RecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
那么这个细线是怎么画出来的呢?回忆一下ItemDecoration中方法的作用,步骤应该是这样的:首先我们通过getItemOffsets()
在item之间添加间隔,然后通过onDraw()
或者onDrawOver()
在这段间隔内绘制线段。
为了验证我们的想法,来看一下DividerItemDecoration的源码。我们使用的时候是垂直方向,代码中水平方向的代码我就省略掉了。
public class DividerItemDecoration extends RecyclerView.ItemDecoration {
private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
private Drawable mDivider;
// 垂直或水平方向
private int mOrientation;
private final Rect mBounds = new Rect();
public DividerItemDecoration(Context context, int orientation) {
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0); // 默认的分隔线
a.recycle();
setOrientation(orientation);
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
if (mOrientation == VERTICAL) {
drawVertical(c, parent);
}
}
private void drawVertical(Canvas canvas, RecyclerView parent) {
canvas.save();
final int left;
final int right;
// 根据RecyclerView是否有padding获取线段的left和right的坐标
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();
}
// 得到当前显示的每个child的线段的top和bottom坐标
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();
}
@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) {
// 设置每个item与下方的间隔为mDivider.getIntrinsicHeight()
outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}
}
}
DividerItemDecoration的实现逻辑果然与我们想的一样,先在getItemOffsets()
中设置每个item与下方的间隔,随后在onDraw(...)
中调用drawVertical(Canvas canvas, RecyclerView parent)
得到线段的边界坐标并绘制。
二、城市列表实现
2.1 分析实现方式
虽然demo中的效果是下一组的Header将上一组的Header顶了上去,但实现的逻辑并非如此,如果把Header的背景色调整为半透明,效果是这样的。
Header半透明之后,它就露出了马脚,仔细观察后我们可以总结实现这个效果所需的步骤:
① 每个分组的第一个城市item的上方都有一个Header,例如“阿坝”和“白城”的上方都有一个Header
② 当前RecyclerView所展示的第一个item的分类会显示在RecyclerView的最上方,例如当前RecyclerView第一个item是“阿克苏”、“安庆”等城市时,RecyclerView最上方会漂浮一个"A"类Header
③ 当某个分组的最后一个item滑出RecyclerView时,Header会随着这个item一起滑走,这也是“顶上去”效果的由来。例如当“澳门”即将滑出RecyclerView时,"A"类Header会随着“澳门”item一起滑走,并且我们很容易得到他们坐标之间的关系:item.bottom = Header.bottom
2.2 具体实现
分析完步骤,即可开始实现这个效果。项目中的城市数据保存在arrays文件中,数据格式如下所示,我已经先为每个城市添加了拼音并为所有城市进行了排序,数据中还包括城市ID,完整数据请去博客开头的github下载。
- 阿坝
- aba
- 101271901
- 阿克苏
- akesu
- 101130801
- 阿勒泰
- aletai
- 101131401
- 阿里
- ali
- 101140701
- 安康
- ankang
- 101110701
- 安庆
- anqing
- 101220601
- 鞍山
- anshan
- 101070301
......
在绘制Header时,我们需要知道一个item是不是它分组的第一个或者是最后一个,那么需要构建这样的一个实体类:
public class CityInfo {
private String mCityName;
private String mPinYin;
private String mGroup;
private String mCityID;
private boolean mIsFirstInGroup;
private boolean mIsLastInGroup;
public CityInfo(String cityName, String pinYin, String cityID,
boolean isFirstInGroup, boolean isLastInGroup) {
this.mCityName = cityName;
this.mPinYin = pinYin;
this.mGroup = mPinYin.substring(0, 1).toUpperCase();
this.mCityID = cityID;
this.mIsFirstInGroup = isFirstInGroup;
this.mIsLastInGroup = isLastInGroup;
}
// setters and getters...
}
再来将数据都解析成实体类。由于数据是已经排好序的,那么判断一个item是不是它分组的第一个或最后一个可以用这种方式:如果第i个数据与第i-1个的group不相同,那么i就是i的分组的第一个item;而i-1就是i-1的分组的最后一个item。
private void prepareCityInfo() {
mCityInfoList = new ArrayList<>();
String[] cityArray = getResources().getStringArray(R.array.city);
String curGroup = "0";
// 每 3 个String构成一个CityInfo
for (int i = 0; i < cityArray.length; i += 3) {
CityInfo cityInfo = new CityInfo(cityArray[i], cityArray[i + 1], cityArray[i + 2],
false, false);
if (!cityInfo.getGroup().equals(curGroup)) {
// 如果当前城市的 group 信息与保存的不一致, 那么就是该 group 的第一个
cityInfo.setIsFirstInGroup(true);
// 同时将该 group 信息添加到索引中
indexList.add(cityInfo.getGroup());
// 它的上一个城市就是上一个 group 的最后一个
if (i > 0) {
mCityInfoList.get(mCityInfoList.size() - 1).setIsLastInGroup(true);
}
curGroup = cityInfo.getGroup();
}
mCityInfoList.add(cityInfo);
}
}
下面重点来看下怎么自定义ItemDecoration,我们根据之前总结的步骤来,首先为每组的第一个item绘制Header,在绘制Header之前需要通过getItemOffsets()
方法为Header预留空间。
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
if (cityInfo.isFirstInGroup()) {
outRect.top = GROUP_ITEM_TOP;
}
}
再通过onDraw()
绘制,这里parent.getChildCount()
获取到的是RecyclerView中当前可见的所有item的数量。
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDraw(c, parent, state);
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View view = parent.getChildAt(i);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
if (cityInfo.isFirstInGroup()) {
int left = parent.getPaddingLeft();
int top = view.getTop() - GROUP_ITEM_TOP;
int right = parent.getRight() - parent.getPaddingRight();
int bottom = view.getTop();
// 绘制背景
c.drawRect(left, top, right, bottom, mBackGroundPaint);
// 绘制文字
drawText(c, left, top, bottom, cityInfo.getGroup());
}
}
}
再来绘制固定于RecyclerView顶端的Header,这里只要得到RecyclerView所展示的第一个item的group并将其绘制即可。由于这个Header是在RecyclerView上层的,因此需要在onDrawOver()
中绘制。
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
// 得到当前所展示的第一个View
View view = parent.getChildAt(0);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
int left = parent.getPaddingLeft();
int top = parent.getPaddingTop();
int right = parent.getRight() - parent.getPaddingRight();
int bottom = top + GROUP_ITEM_TOP;
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
}
最后需要实现就是Header随着当前group的最后一个item移动的效果,移动时Header的bottom与item的bottom一致即可。那什么时候开始移动呢?很显然就是当这个item的bottom与RecyclerView顶部的距离小于Header的高度时开始移动。我们修改onDrawOver()
方法如下即可。
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
super.onDrawOver(c, parent, state);
View view = parent.getChildAt(0);
int position = parent.getChildAdapterPosition(view);
CityInfo cityInfo = mCityInfoList.get(position);
// 当前第一个item是它group的最后一个
// 且view的bottom距离RecyclerView顶端小于Header的高度
if (cityInfo.isLastInGroup() && view.getBottom() < GROUP_ITEM_TOP) {
int left = parent.getPaddingLeft();
int top = view.getBottom() - GROUP_ITEM_TOP;
int right = parent.getRight() - parent.getPaddingRight();
int bottom = view.getBottom();
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
} else {
int left = parent.getPaddingLeft();
int top = parent.getPaddingTop();
int right = parent.getRight() - parent.getPaddingRight();
int bottom = top + GROUP_ITEM_TOP;
c.drawRect(left, top, right, bottom, mBackGroundPaint);
drawText(c, left, top, bottom, cityInfo.getGroup());
}
}
代码介绍到这里就结束了,如果你对整体的程序感兴趣,欢迎去github下载。
三、参考
RecyclerView探索之通过ItemDecoration实现StickyHeader效果