RecyclerView: (转) 仿iOS的分组吸顶效果
本文转自:https://www.jianshu.com/p/26b0911f396f
之前写过一篇文章《Android开发之仿微博详情页(滑动固定顶部栏效果)》,当时采用的解决方案是用一个ScrollView去包裹内容布局,通过监听滑动状态,在适当的时候,移入/移出所要固定的布局,这样虽然可以达到想要的视觉效果,但这种实现方式并不优雅,比如被包裹内容布局中带有滑动特性的View(ListView,RecyclerView等),这样做就需要我们额外的去处理滑动冲突,而且这种方式的包裹会使得它们的缓存机制失效,为了一个视觉效果去牺牲它们最具灵动的特性的一面,我不提倡这种做法。
这里介绍另外一种解决方案,可以更加优雅的实现这种视觉效果,而且不会有滑动冲突,也不需要牺牲缓存机制,在文章末尾会给出思路,需要你先看完文章哈~
先来看下今天要实现的效果图:
要实现这个效果很简单,只需要一个RecyclerView就可以实现了,不需要多余的布局控件,当然网上也有另外的一些实现方式,比如利用帧布局或者相对布局在RecyclerView上面再盖上要固定的ViewGroup,通过滑动去判断是否需要动态的将固定布局移入/移出,其实和上面提到的文章实现思路一样,这样做,很明显的会有几个缺点,比如增加了布局的深度或者在业务发生变化的时候需要同时去改动至少两处代码等,如果中间还耦合着一些业务操作,出错几率也会对应的增加。
列表的组成
这是一个带有分组的列表,我们可以把它拆分成3部分,头部数据+列表数据+分割线
列表数据:
RecyclerView的基本使用,这里我就不再重复阐述了。
分割线:
要实现分割线,如果是在以前的ListView,我们通过设置divider,dividerHeigh等属性就可以很轻松的达到目的,或者直接在布局文件中画上一个带有高度和背景色的View来实现。到了RecyclerView这里,我们可不再需要这样做了,官方给我们提供了一个强大的装饰器ItemDecoration,它可以帮助我们实现分割线的功能,但它可不仅仅只能实现分割线,一会下文会介绍。
头部数据:
要绘制这个头部,以前我们在ListView里,可能有些人会这样做,让每个Item布局都带上这个头部布局,然后根据是否是每组数据的第一个来动态控制头部布局是显示还是隐藏,当然这样做也可以实现我们想要的效果,但却会多余的去耗费一定的性能,因为明明每组数据只需要绘制一个头部,而你却每个Item都去绘制,最终每组却又只需要一个,所以这里我们依然可以采用官方提供的ItemDecoration来解决这个问题。
什么是ItemDecoration?
说了这么多ItemDecoration,我们来看下官方给出的介绍吧:
An ItemDecoration allows the application to add a special drawing and layout offset to
specific item views from the adapter's data set. This can be useful for drawing dividers
between items, highlights, visual grouping boundaries and more.
大概意思是ItemDecoration允许给特定的item视图添加特性的绘制以及布局间隔。它可以用来实现item之间的分割线,高亮,分组边界等。
public class ItemDecoration extends RecyclerView.ItemDecoration { @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDraw(c, parent, state); } @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { super.onDrawOver(c, parent, state); } @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { super.getItemOffsets(outRect, view, parent, state); } }
ItemDecoration是RecyclerView下的抽象方法,我们要使用它只需要继承它,并实现对应的方法即可,然后让RecyclerView去调用它:
mRecyclerView.addItemDecoration(new ItemDecoration());
具体来看下这3个方法,顺便来一张图帮助理解:
getItemOffsets:它是用来给Item设置间距的,可以这样理解在Item外还有一层布局,而这个方法是用来设置布局的Padding。
onDraw:它的绘制和Item之下,它绘制的东西会在Item的下面。
onDrawOver:它的绘制在Item之上,它绘制的东西会覆盖在Item的上面。
事实上并不是真的有层次之分,这里只是为了方便理解,最根本的原因是因为它们方法的调用方法的顺序,又因为都作用于同一个Canvas上,才出现这种覆盖的层次的效果。
知道了这些方法的作用后,我们配合RecyclerView给我们的一些API方法,要做其它事情容易多了,随意举2个例子:
1、如果我们想要绘制分割线,只需要先调用getItemOffsets,让Item空出一定的间隙,然后再调用onDraw在这个间隙上填充颜色即可。
2、我们经常会遇到一些节假日活动的需求,需要在列表上的边角处标记“活动”,“特价”等特殊符号,这时候我们只需要调用onDrawOver在Item上绘制即可。
言归正传,我们来看下今天我们要实现的效果,带有吸顶效果的分组列表,上文已经提及了可以分为3部分来看,头部数据+列表数据+分割线,其中列表数据是最基础的RecyclerView的使用,这个我们就不说了,我们来看下其它2部分。
为了测试方便,这里我建立了一些本地数据:
数据实体:
public class Bean { private String text; private String groupName; public Bean(String text, String groupName) { this.text = text; this.groupName = groupName; } public String getText() { return text; } public void setText(String text) { this.text = text; } public String getGroupName() { return groupName; } public void setGroupName(String groupName) { this.groupName = groupName; } }
数据集合:
List<Bean> beanList = new ArrayList<>(); for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第一组%d号", i + 1), "第一组")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第二组%d号", i + 1), "第二组")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第三组%d号", i + 1), "第三组")); } for (int i = 0; i < 6; i++) { beanList.add(new Bean(String.format("第四组%d号", i + 1), "第四组")); }
分割线的实现:
首先我们需要在getItemOffsets方法中让Item间空出空隙:
@Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { outRect.bottom = 1; }
然后我们在onDraw方法中去对这个空隙绘制颜色(绘制一个带有颜色矩形)
@Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); c.drawRect(0, view.getBottom(), parent.getWidth(), view.getBottom() + 1, mLinePaint); } }
这里需要注意的一个地方是RecyclerView的getChildCount方法只会拿到当前可视区域的Item项,然后我们对Item进行遍历绘制矩形(分割线)。
就这么简单,我们的分割线已经画好了,看下实现效果:
头部布局的实现:
头布局的实现和分割线是一样的,它一样需要让Item空出空隙,然后填充颜色,只是空出的空隙距离和颜色不一样罢了,所以我们需要知道什么时候空出的分割线的空隙,什么时候空出头部布局的空隙,这个就和我们数据源有关系了,我们写一个方法来判断当前position所对应的Item项是不是每组数据的第一项:
/** * 判断position对应的Item是否是组的第一项 * * @param position * @return */ public boolean isItemHeader(int position) { if (position == 0) { return true; } else { String lastGroupName = mList.get(position - 1).getGroupName(); String currentGroupName = mList.get(position).getGroupName(); //判断上一个数据的组别和下一个数据的组别是否一致,如果不一致则是不同组,也就是为第一项(头部) if (lastGroupName.equals(currentGroupName)) { return false; } else { return true; } } }
然后来看下getItemOffsets方法,如果是每组第一项我们空出头部布局的高度,如果不是,我们则空出分割线的高度:
/** * 设置Item的间距 * * @param outRect * @param view * @param parent * @param state */ @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { outRect.top = mItemHeaderHeight; } else { outRect.top = 1; } } }
然后一样的在onDraw方法里绘制背景颜色和文字即可,关于绘制的知识点这边就不说了,属于基础的自定义View需要掌握的知识:
/** * 绘制Item的分割线和组头 * * @param c * @param parent * @param state */ @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int count = parent.getChildCount(); for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), view.getTop(), mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } else { c.drawRect(0, view.getTop() - 1, parent.getWidth(), view.getTop(), mLinePaint); } } } }
此时我们的头部布局也画好了,看下实现效果:
吸顶效果的实现:
关于吸顶的效果,其实只要我们理清楚它的流程就会发现其实并不复杂,50行代码不到就可以把它完成。
首先我们需要知道以下几点:
1、当我们滑动列表的时候,第一个头布局是固定在我们的列表顶部的。
2、通过滑动列表,当下一个头布局和第一个头布局相碰的时候,会把第一个布局“顶出去”,当第一个头布局完全被“顶出去”后,第二个头布局并替代了第一个头布局固定在列表顶部。
知道了上面2点后,有时候我们所看到的视觉效果会把我们带入一个思维误区,比如这个吸顶效果,有的朋友可能会这样去考虑,是不是需要在滑动的时候,动态的去改变getItemOffsets的空隙大小和在onDraw的绘制高度。如果真的这样去做,你会发现实现起来十分困难。
我们换一种思维,既然顶部的布局是固定不动的,是不是可以利用onDrawOver在RecyclerView的上绘制一个和头部布局一模一样的布局呢,让它覆盖住了第一个头布局,在视觉上我们是不会有所察觉的,然后当列表滑动的时候,其实“原来的头布局”早已经滑动走了,留下的其实是我们绘制的固定布局而已,等到下一个头部布局“碰头”的时候,让它随着滑动的速度慢慢改变布局的高度,当布局高度为0的时候,也就是被顶出去的时候,然后再让高度改变回来,覆盖住第二个布局,然后不断重复以上步骤。
可能说的有点抽象,我们来一张图看一下,这次我故意把头布局颜色改成红色,不清楚的朋友多看几次就可以理解了。
看下具体代码,我们先通过findFirstVisibleItemPosition拿到第一个可见的Item的position,那我们就可以根据position+1可以知道下一个Item是否是另一组的头布局(判断组名是否发生了变化),如果不是,我们的依旧绘制固定布局即可,如果是,我们根据第一个可见Item的getBottom值的变小,慢慢的改变固定布局的高度,直到被“顶出去”。
/** * 绘制Item的顶部布局(吸顶效果) * * @param c * @param parent * @param state */ @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); View view = parent.findViewHolderForAdapterPosition(position).itemView; boolean isHeader = adapter.isItemHeader(position + 1); if (isHeader) { int bottom = Math.min(mItemHeaderHeight, view.getBottom()); c.drawRect(0, view.getTop() - mItemHeaderHeight, parent.getWidth(), bottom, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint); } else { c.drawRect(0, 0, parent.getWidth(), mItemHeaderHeight, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), mTextPaddingLeft, mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } } }
吸顶效果就这么简单的完成了,其实关键就在于onDrawOver这个方法。
这里附上完整的ItemDecoration代码(避免太多参数增加代码阅读难度,上面的讲解没有考虑RecyclerView存在Padding的情况,这边已给出补充):
package com.lcw.view.stickheaderview; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.View; /** * 自定义装饰器(实现分组+吸顶效果) * Create by: chenWei.li * Date: 2018/11/2 * Time: 上午1:14 * Email: lichenwei.me@foxmail.com */ public class StickHeaderDecoration extends RecyclerView.ItemDecoration { //头部的高 private int mItemHeaderHeight; private int mTextPaddingLeft; //画笔,绘制头部和分割线 private Paint mItemHeaderPaint; private Paint mTextPaint; private Paint mLinePaint; private Rect mTextRect; public StickHeaderDecoration(Context context) { mItemHeaderHeight = dp2px(context, 40); mTextPaddingLeft = dp2px(context, 6); mTextRect = new Rect(); mItemHeaderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mItemHeaderPaint.setColor(Color.BLUE); mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setTextSize(46); mTextPaint.setColor(Color.WHITE); mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLinePaint.setColor(Color.GRAY); } /** * 绘制Item的分割线和组头 * * @param c * @param parent * @param state */ @Override public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int count = parent.getChildCount();//获取可见范围内Item的总数 for (int i = 0; i < count; i++) { View view = parent.getChildAt(i); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); int left = parent.getPaddingLeft(); int right = parent.getWidth() - parent.getPaddingRight(); if (isHeader) { c.drawRect(left, view.getTop() - mItemHeaderHeight, right, view.getTop(), mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, (view.getTop() - mItemHeaderHeight) + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } else { c.drawRect(left, view.getTop() - 1, right, view.getTop(), mLinePaint); } } } } /** * 绘制Item的顶部布局(吸顶效果) * * @param c * @param parent * @param state */ @Override public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition(); View view = parent.findViewHolderForAdapterPosition(position).itemView; boolean isHeader = adapter.isItemHeader(position + 1); int top = parent.getPaddingTop(); int left = parent.getPaddingLeft(); int right = parent.getWidth() - parent.getPaddingRight(); if (isHeader) { int bottom = Math.min(mItemHeaderHeight, view.getBottom()); c.drawRect(left, top + view.getTop() - mItemHeaderHeight, right, top + bottom, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2 - (mItemHeaderHeight - bottom), mTextPaint); } else { c.drawRect(left, top, right, top + mItemHeaderHeight, mItemHeaderPaint); mTextPaint.getTextBounds(adapter.getGroupName(position), 0, adapter.getGroupName(position).length(), mTextRect); c.drawText(adapter.getGroupName(position), left + mTextPaddingLeft, top + mItemHeaderHeight / 2 + mTextRect.height() / 2, mTextPaint); } c.save(); } } /** * 设置Item的间距 * * @param outRect * @param view * @param parent * @param state */ @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { if (parent.getAdapter() instanceof RecyclerViewAdapter) { RecyclerViewAdapter adapter = (RecyclerViewAdapter) parent.getAdapter(); int position = parent.getChildLayoutPosition(view); boolean isHeader = adapter.isItemHeader(position); if (isHeader) { outRect.top = mItemHeaderHeight; } else { outRect.top = 1; } } } /** * dp转换成px */ private int dp2px(Context context, float dpValue) { float scale = context.getResources().getDisplayMetrics().density; return (int) (dpValue * scale + 0.5f); } }
<END>