实现上图列表的粘性头部功能一般通过在布局页面额外写粘性头部View
,然后通过监听列表的滑动来控制显示隐藏粘性头部View
。而如果列表使用RecyclerView
实现,那么就能通过自定义ItemDecoration
达到目的。下面先简单介绍ItemDecoration
。
ItemDecoration
ItemDecoration
是RecyclerView
的静态内部类,它包含三个方法:
getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
onDraw(Canvas c, RecyclerView parent, State state)
onDrawOver(Canvas c, RecyclerView parent, State state)
通过重写上述三个方法,RecyclerView
可以实现添加分隔线,每个item添加标签/蒙层,分组粘性头部等其他更高级的功能。
#######getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
这个方法可以通过给outRect的left、top、right、bottom,实现类似padding的效果。如下图所示:
#######onDraw(Canvas c, RecyclerView parent, State state)
这个方法可以实现类似绘制背景的效果,绘制的东西是显示在item的下层,一般配合getItemOffsets()
方法使用。通过getItemOffsets()
方法设置outRect,如果绘制在outRect设置的范围内,可见;超出设置的范围,由于是绘制在item的下面,所以并不可见。
#######onDrawOver(Canvas c, RecyclerView parent, State state)
这个方法是绘制在内容的上面,绘制区域不受限制
调用顺序
由上图可以得出以下几条信息:
- 上面上个方法的调用顺序依次为:
getItemOffsets()
,onDraw()
,onDrawOver()
; -
getItemOffsets()
针对每一个item,它调用的次数即为屏幕上绘制item的个数; -
onDraw()
,onDrawOver()
方法针对RecyclerView
本身,初始化只会调用一次;
当滑动列表至第10条的过程中,可以看到
onDraw()
,
onDrawOver()
两个方法在反复的调用。我们先看下这两个方法在
RecyclerView
中调用位置,从下面也可以看得出来decoration 的
onDraw()
,child view 的
onDraw()
,decoration 的
onDrawOver()
,这三者是依次发生的。
@Override
public void draw(Canvas c) {
super.draw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
//以下代码省略
}
@Override
public void onDraw(Canvas c) {
super.onDraw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDraw(c, this, mState);
}
}
RecyclerView
的滚动分为两个阶段,手指在屏幕上列表的scroll和手指离开屏幕列表的fling,这两个阶段最终都会执行下面这段代码:
if (!mItemDecorations.isEmpty()) {
invalidate();
}
当绘制的ItemDecoration数量不为空时,RecyclerView
会不断的重绘,这样就会调用RecyclerView
的onDraw()
,onDrawOver()
方法,因此ItemDecoration
的这两个方法就在不断的调用。关于RecyclerView
的滑动源码分析具体可参看 RecyclerView剖析
StickyHeader
关于开头gif图片的实现如下:
- 列表数据有50条,每5条为一组,adapter的实现
public class RecyclerViewAdapter extends RecyclerView.Adapter {
private Context mContext;
private List datas;
public RecyclerViewAdapter(Context context) {
this.mContext = context;
}
public void setData(List datas) {
this.datas = datas;
}
@Override
public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new MyViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item, parent, false));
}
@Override
public void onBindViewHolder(MyViewHolder holder, int position) {
holder.populate(datas.get(position));
}
@Override
public int getItemCount() {
return datas.size();
}
//是否存在分组的头部,每5个一组
public boolean hasHeader(int pos) {
if (pos % 5 == 0) {
return true;
} else {
return false;
}
}
//采用xml方式来实现ItemDecoration,可以更方便的定制ItemDecoration的内容,生成head布局
public HeaderHolder onCreateHeaderViewHolder(ViewGroup parent) {
return new HeaderHolder(LayoutInflater.from(mContext).inflate(R.layout.item_decoration, parent, false));
}
//绑定head的数据
public void onBindHeaderViewHolder(HeaderHolder viewholder, int position) {
viewholder.group.setText("分组" + getHeaderId(position));
viewholder.clickgroup.setText("点击分组" + getHeaderId(position));
}
//获取每条数据属于哪一分组
public int getHeaderId(int position) {
return position / 5;
}
public class HeaderHolder extends RecyclerView.ViewHolder {
TextView group;
TextView clickgroup;
public HeaderHolder(View itemView) {
super(itemView);
group = (TextView) itemView.findViewById(R.id.tv);
clickgroup = (TextView) itemView.findViewById(R.id.tv1);
clickgroup.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(mContext,clickgroup.getText().toString(),0).show();
}
});
}
}
public class MyViewHolder extends RecyclerView.ViewHolder {
TextView tv_item_layout;
String str;
public MyViewHolder(View itemView) {
super(itemView);
tv_item_layout = (TextView) itemView.findViewById(R.id.tv_item_layout);
tv_item_layout.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(mContext,str,0).show();
}
});
}
public void populate(String str) {
tv_item_layout.setText(str);
this.str = str;
}
}
}
-
getItemOffsets()
方法实现
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
//得到该view在列表中的位置
int position = parent.getChildAdapterPosition(view);
int headerHeight = 0;
//判断这个位置是否有分组的头部
if (position != RecyclerView.NO_POSITION && hasHeader(position)) {
//获取到header所需要的高度
View header = getHeader(parent, position);
headerHeight = header.getHeight();
}
outRect.set(0, headerHeight, 0, 0);
}
此方法的目的很简单,就是判断当前加载的item是否需要header,需要就获取header高度,并设置给outRect。然后是判断是否需要header的方法hasHeader(position)
,调用adapter的hasHeader(position)
方法,每组的第一个添加头部。
/**
* 判断是否有header
*
* @param position
* @return
*/
private boolean hasHeader(int position) {
return mAdapter.hasHeader(position);
}
获取头部高度的方法:
/**
* 获得自定义的Header
*
* @param parent
* @param position
* @return
*/
public View getHeader(RecyclerView parent, int position) {
//根据位置获取每一组的头部id
final int headerId = mAdapter.getHeaderId(position);
//通过头部id,从保存的头部view数组中获取改组的头部view
View header = mHeaderViews.get(headerId);
//如果为空,就通过adapert创建
if (header == null) {
//创建HeaderViewHolder
RecyclerViewAdapter.HeaderHolder holder = mAdapter.onCreateHeaderViewHolder(parent);
header = holder.itemView;
//绑定数据
mAdapter.onBindHeaderViewHolder(holder, position);
//测量View并且layout
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
//根据父View的MeasureSpec和子view自身的LayoutParams以及padding来获取子View的MeasureSpec
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width);
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height);
//进行测量
header.measure(childWidth, childHeight);
//根据测量后的宽高放置位置
header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
//将创建好的头部view保存在数组中,避免每次重复创建
mHeaderViews.put(headerId, header);
}
return header;
}
header的创建可以参看上面adapter的代码。
-
onDrawOver()
方法实现
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
//mHeaderRects为存放屏幕上显示的header的点击区域,每次重新绘制头部的时候清空数据
mHeaderRects.clear();
final int count = parent.getChildCount();
//遍历屏幕上加载的item
for (int layoutPos = 0; layoutPos < count; layoutPos++) {
final View child = parent.getChildAt(layoutPos);
//获取该item在列表数据中的位置
final int adapterPos = parent.getChildAdapterPosition(child);
//只有在最上面一个item或者有header的item才绘制header
if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) {
View header = getHeader(parent, adapterPos);
c.save();
//获取绘制header的起始位置(left,top)
final int left = child.getLeft();
final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos);
//将画布移动到绘制的位置
c.translate(left, top);
//绘制header
header.draw(c);
c.restore();
//保存绘制的header的区域
mHeaderRects.put(adapterPos, new Rect(left, top, left+header.getWidth(), top+header.getHeight()));
}
}
}
因为onDrawOver()
是针对RecyclerView
的,所以需要循环绘制出来的item,在需要header的地方进行绘制。在获取绘制坐标的时候,主要在于确定纵坐标的起始位置距离顶部的大小。
/**
* 计算距离顶部的高度
*
* @param parent
* @param child
* @param header
* @param adapterPos
* @param layoutPos
* @return
*/
private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
int headerHeight = header.getHeight();
int top = ((int) child.getY()) - headerHeight;
//在绘制最顶部的header的时候,需要考虑处理两个分组的header交换时候的情况
if (layoutPos == 0) {
final int count = parent.getChildCount();
final int currentId = mAdapter.getHeaderId(adapterPos);
//从第二个屏幕上线上的第二个item开始遍历
for (int i = 1; i < count; i++) {
int nextpos = parent.getChildAdapterPosition(parent.getChildAt(i));
if (nextpos != RecyclerView.NO_POSITION) {
int nextId = mAdapter.getHeaderId(nextpos);
//找到下一个不同组的view
if (currentId != nextId) {
final View next = parent.getChildAt(i);
//当不同组的第一个view距离顶部的位置减去两组header的高度,得到offset
final int offset = ((int) next.getY()) - (headerHeight + getHeader(parent, nextpos).getHeight());
//offset小于0即为两组开始交换,第一个header被挤出界面的距离
if (offset < 0) {
return offset;
} else {
break;
}
}
}
}
top = Math.max(0, top);
}
return top;
}
如果view不是屏幕上第一个item时,header距离顶部直接就是此view距离顶部距离减去header的高度即可,如果view是屏幕上第一个item时,然后找到和它不同组的第一个view,计算出offset的值,当这个距离大于0时,代表此view的header还全部显示出来,这时直接用上面的方式获取这个距离,当这个距离小于0时offset就是此view的header的绘制起点。
以上就是StickyHeader的全部代码,接下来是关于StickyHeader的点击事件处理
StickyHeader的点击事件
RecyclerView
给我们提供了一个addOnItemTouchListener()
方法用来监听每个item的点击事件,我们可以自定义一个RecyclerView.OnItemTouchListener
进行相应的逻辑处理,达到header的点击目的。下面是自定义的RecyclerView.OnItemTouchListener
的完整代码。
public class StickyRecyclerHeadersTouchListener implements RecyclerView.OnItemTouchListener {
private final GestureDetector mTapDetector;
private final RecyclerView mRecyclerView;
private final TestDecoration mDecor;
public StickyRecyclerHeadersTouchListener(final RecyclerView recyclerView,
final TestDecoration decor) {
mTapDetector = new GestureDetector(recyclerView.getContext(), new SingleTapDetector());
mRecyclerView = recyclerView;
mDecor = decor;
}
@Override
public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) {
//将事件交给GestureDetector类进行处理,通过onSingleTapUp返回的值,判断是否要拦截事件
boolean tapDetectorResponse = this.mTapDetector.onTouchEvent(e);
if (tapDetectorResponse) {
// Don't return false if a single tap is detected
return true;
}
//如果是点击在header区域,则拦截事件
if (e.getAction() == MotionEvent.ACTION_DOWN) {
int position = mDecor.findHeaderPositionUnder((int) e.getX(), (int) e.getY());
return position != -1;
}
return false;
}
@Override
public void onTouchEvent(RecyclerView view, MotionEvent e) { /* do nothing? */ }
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
// do nothing
}
private class SingleTapDetector extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onSingleTapUp(MotionEvent e) {
//根据点击的坐标查找是不是点击在header的区域
int position = mDecor.findHeaderPositionUnder((int) e.getX(), (int) e.getY());
if (position != -1) {
//如果position不等于-1,则表示点击在header区域,然后在判断是否在header需要响应的区域
View headerView = mDecor.getHeader(mRecyclerView, position);
View view1 = headerView.findViewById(R.id.tv1);
if (mDecor.findHeaderClickView(view1, (int) e.getX(), (int) e.getY())) {
//如果在header需要响应的区域,该区域的view模拟点击
view1.performClick();
}
mRecyclerView.playSoundEffect(SoundEffectConstants.CLICK);
headerView.onTouchEvent(e);
return true;
}
return false;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
return true;
}
}
}
StickyRecyclerHeadersTouchListener
主要思路就是通过将item的触摸事件交给GestureDetector
进行处理,然后判断点击的区域是否在屏幕上的某个header上,如果在就拦截事件,交给header响应该点击事件。下面是在ItemDecrotion
中判断点击坐标是否在header的区域内的方法
public int findHeaderPositionUnder(int x, int y) {
//遍历屏幕上header的区域,判断点击的位置是否在某个header的区域内
for (int i = 0; i < mHeaderRects.size(); i++) {
Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
if (rect.contains(x, y)) {
return mHeaderRects.keyAt(i);
}
}
return -1;
}
判断是否在header需要响应点击事件的区域
public boolean findHeaderClickView(View view, int x, int y) {
if (view == null) return false;
for (int i = 0; i < mHeaderRects.size(); i++) {
Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
if (rect.contains(x, y)) {
Rect vRect = new Rect();
// 需要响应点击事件的区域在屏幕上的坐标
vRect.set(rect.left + view.getLeft(), rect.top + view.getTop(), rect.left + view.getLeft() + view.getWidth(), rect.top + view.getTop() + view.getHeight());
return vRect.contains(x, y);
}
}
return false;
}
关于StickyHeader的点击事件的分析就告一段落了。最后贴上自定义的ItemDecrotion
的完整代码。
public class TestDecoration extends RecyclerView.ItemDecoration {
private RecyclerViewAdapter mAdapter;
private final SparseArray mHeaderRects = new SparseArray<>();
private final LongSparseArray mHeaderViews = new LongSparseArray<>();
public TestDecoration(RecyclerViewAdapter mAdapter) {
super();
this.mAdapter = mAdapter;
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
int position = parent.getChildAdapterPosition(view);
int headerHeight = 0;
//在使用adapterPosition时最好的加上这个判断
if (position != RecyclerView.NO_POSITION && hasHeader(position)) {
//获取到ItemDecoration所需要的高度
View header = getHeader(parent, position);
headerHeight = header.getHeight();
}
outRect.set(0, headerHeight, 0, 0);
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDraw(c, parent, state);
// Log.e("TestDecoration", "onDraw()..........");
}
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
super.onDrawOver(c, parent, state);
//mHeaderRects为存放屏幕上显示的header的点击区域,每次重新绘制头部的时候清空数据
mHeaderRects.clear();
final int count = parent.getChildCount();
//遍历屏幕上加载的item
for (int layoutPos = 0; layoutPos < count; layoutPos++) {
final View child = parent.getChildAt(layoutPos);
//获取该item在列表数据中的位置
final int adapterPos = parent.getChildAdapterPosition(child);
//只有在最上面一个item或者有header的item才绘制header
if (adapterPos != RecyclerView.NO_POSITION && (layoutPos == 0 || hasHeader(adapterPos))) {
View header = getHeader(parent, adapterPos);
c.save();
//获取绘制header的起始位置(left,top)
final int left = child.getLeft();
final int top = getHeaderTop(parent, child, header, adapterPos, layoutPos);
//将画布移动到绘制的位置
c.translate(left, top);
//绘制header
header.draw(c);
c.restore();
//保存绘制的header的区域
mHeaderRects.put(adapterPos, new Rect(left, top, left + header.getWidth(), top + header.getHeight()));
}
}
}
public int findHeaderPositionUnder(int x, int y) {
for (int i = 0; i < mHeaderRects.size(); i++) {
Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
if (rect.contains(x, y)) {
return mHeaderRects.keyAt(i);
}
}
return -1;
}
public boolean findHeaderClickView(View view, int x, int y) {
if (view == null) return false;
for (int i = 0; i < mHeaderRects.size(); i++) {
Rect rect = mHeaderRects.get(mHeaderRects.keyAt(i));
if (rect.contains(x, y)) {
Rect vRect = new Rect();
// 需要响应点击事件的区域在屏幕上的坐标
vRect.set(rect.left + view.getLeft(), rect.top + view.getTop(), rect.left + view.getLeft() + view.getWidth(), rect.top + view.getTop() + view.getHeight());
return vRect.contains(x, y);
}
}
return false;
}
/**
* 判断是否有header
*
* @param position
* @return
*/
private boolean hasHeader(int position) {
return mAdapter.hasHeader(position);
}
/**
* 获得自定义的Header
*
* @param parent
* @param position
* @return
*/
public View getHeader(RecyclerView parent, int position) {
//根据位置获取每一组的头部id
final int headerId = mAdapter.getHeaderId(position);
//通过头部id,从保存的头部view数组中获取改组的头部view
View header = mHeaderViews.get(headerId);
//如果为空,就通过adapert创建
if (header == null) {
//创建HeaderViewHolder
RecyclerViewAdapter.HeaderHolder holder = mAdapter.onCreateHeaderViewHolder(parent);
header = holder.itemView;
//绑定数据
mAdapter.onBindHeaderViewHolder(holder, position);
//测量View并且layout
int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);
//根据父View的MeasureSpec和子view自身的LayoutParams以及padding来获取子View的MeasureSpec
int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
parent.getPaddingLeft() + parent.getPaddingRight(), header.getLayoutParams().width);
int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
parent.getPaddingTop() + parent.getPaddingBottom(), header.getLayoutParams().height);
//进行测量
header.measure(childWidth, childHeight);
//根据测量后的宽高放置位置
header.layout(0, 0, header.getMeasuredWidth(), header.getMeasuredHeight());
//将创建好的头部view保存在数组中,避免每次重复创建
mHeaderViews.put(headerId, header);
}
return header;
}
/**
* 计算距离顶部的高度
*
* @param parent
* @param child
* @param header
* @param adapterPos
* @param layoutPos
* @return
*/
private int getHeaderTop(RecyclerView parent, View child, View header, int adapterPos, int layoutPos) {
int headerHeight = header.getHeight();
int top = ((int) child.getY()) - headerHeight;
//在绘制最顶部的header的时候,需要考虑处理两个分组的header交换时候的情况
if (layoutPos == 0) {
final int count = parent.getChildCount();
final int currentId = mAdapter.getHeaderId(adapterPos);
//从第二个屏幕上线上的第二个item开始遍历
for (int i = 1; i < count; i++) {
int nextpos = parent.getChildAdapterPosition(parent.getChildAt(i));
if (nextpos != RecyclerView.NO_POSITION) {
int nextId = mAdapter.getHeaderId(nextpos);
//找到下一个不同组的view
if (currentId != nextId) {
final View next = parent.getChildAt(i);
//当不同组的第一个view距离顶部的位置减去两组header的高度,得到offset
final int offset = ((int) next.getY()) - (headerHeight + getHeader(parent, nextpos).getHeight());
//offset小于0即为两组开始交换,第一个header被挤出界面的距离
if (offset < 0) {
return offset;
} else {
break;
}
}
}
}
top = Math.max(0, top);
}
return top;
}
}
最后
最后推荐关于几篇关于ItemDecoration
使用和分析,本篇文章也参考了许多。
RecyclerView之ItemDecoration由浅入深
深入理解 RecyclerView 系列之一:ItemDecoration
StickHeaderItemDecoration--RecyclerView使用的固定头部装饰类
小甜点,RecyclerView 之 ItemDecoration 讲解及高级特性实践