这里写个简单的例子,给 RecyclerView 添加一个红色的分割线。上一章中,我们写了这么个简单的例子,现在就简单的分析一下。
public class ColorDividerItemDecoration extends RecyclerView.ItemDecoration {
final static String TAG = "ColorDividerItem";
private float mDividerHeight;
private Paint mPaint;
public ColorDividerItemDecoration() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(Color.RED);
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
// 第一个ItemView不需要在上面绘制分割线
if (parent.getChildAdapterPosition(view) != 0){
//这里直接硬编码为1px
outRect.top = 1;
mDividerHeight = 1;
}
}
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
int childCount = parent.getChildCount();
for ( int i = 0; i < childCount; i++ ) {
View view = parent.getChildAt(i);
int index = parent.getChildAdapterPosition(view);
//第一个ItemView不需要绘制
if ( index == 0 ) {
continue;
}
float dividerTop = view.getTop() - mDividerHeight;
float dividerLeft = parent.getPaddingLeft();
float dividerBottom = view.getTop();
float dividerRight = parent.getWidth() - parent.getPaddingRight();
c.drawRect(dividerLeft,dividerTop,dividerRight,dividerBottom,mPaint);
}
}
}
我们使用 LinearLayout 布局时,属性设置为垂直,往里面塞了三个view,那么三个view会依次从上到下排布;如果是 FrameLayout 布局,没有设置 gravity 属性,那么三个view会按顺序依次叠加在一起;使用 ListView 时,我们能看到 item 一条接一条,紧密挨在一起;使用 RecyclerView 时,设置垂直属性,效果与 ListView 雷同。刚开始时,这样的效果给我造成了一个错觉,一直觉得 item 在复用,item 之间就应该是紧挨在一起,没有空隙的,后来随着接触的东西多了,见识广了,才知道当初的无知有多么的可笑。ViewGroup 中的子view的大小与布局,都在父控件的掌控之中,换句话说,父控件决定子控件的位置,所以只要我们喜欢,完全可以让item之间隔个100像素,左边都不与父控件左边对齐。上一章中,我们看到了 RecyclerView 在布局item时,把从 ItemDecoration 中 getItemOffsets() 方法中获取的数据都用上了,这就导致 layout 的时候,如果 getItemOffsets() 中返回有值,那么子view也就是 item 之间就有间隙了。这个时候,item 会绘制出自己的布局,间隙就显示父控件的背景颜色,就像上面 ColorDividerItemDecoration 中,如果我们把 onDraw() 方法中的代码移除,同时在 xml 布局中把 RecyclerView 的背景色设置为红色,此时也能达到 item 之间添加红线的效果。
我们不通过xml这种方法,而是在 ItemDecoration 把它给画出来,并且还能实现更复杂的画面。我们看看 onDraw() 方法中,首先是 int childCount = parent.getChildCount() 获取到 RecyclerView 的子view 的个数,注意假如 childCount 为8,则说明 RecyclerView 中有8个子view,并不代表 Adapter 中只有8个item,因为子 view 是可以复用的,所以我们根据 View view = parent.getChildAt(i) 这里的 i 与 Adapter 中的 position 并不是一回事,因此才有了 getChildAdapterPosition(view) 方法,通过反查来查出了 position 的值,即当前 item 在 RecyclerView 中的位置。item之间的红色间隔线,是从0开始,倒数第二个结束,最后一个item是没有间隔线,我们有两种绘制方法,一是在item的底部绘制,最后一条不绘制;二是在item的顶部绘制,第0个item不绘制。这里我们用的是方法二,所以当 index 为 0 时,跳过当前。view.getTop() 获取的是 item 距离 RecyclerView 顶部的距离,暂时可以认为随着 RecyclerView 上下滑动,每个item的 top 值是不停变化的,由于我们要绘制间隔线,所以item的top就是间隔线的bottom,那间隔线的top就是在底部的基础上向上移动 mDividerHeight 的距离,由于屏幕左上角为坐标原点,所以间隔线的top就是在bottom的基础上减去 mDividerHeight;绘制间隔线,没能超过父容器的约束的距离,比如RecyclerView 设置了 padding 值,我们也要遵守,所以 left 就要从父容器的 paddingLeft 开始,right 则要父容器的宽度减去 paddingRight。计算出间隔线的四个顶点的坐标后,用 Canvas 和 Paint 把它画出来,就像在自定义view控件中一样,就是这样。
如果我们想实现微信中通讯录模块,一个列表按照姓氏分类,拼音的首字母现在在同类型的最上端类似的功能,我们就可以像做分割线一样来实现它,还有一种思路,就是在item中显示拼音首字母,根据名字中计算哪个需要隐藏,哪个需要显示。我们先说说用item实现,再说用 ItemDecoration 实现。
public List
List
for (int index = 0; index < 50; index++) {
if (index < 15) {
list.add(new Bean(
"粘性文本1", "name" + index));
} else if (index < 25) {
list.add(new Bean(
"粘性文本2", "name" + index));
} else if (index < 35) {
list.add(new Bean(
"粘性文本3", "name" + index));
} else {
list.add(new Bean(
"粘性文本4", "name" + index));
}
}
return list;
}
class StickyAdapter extends RecyclerView.Adapter
//第一个吸顶
static final int FIRST_STICKY_VIEW = 1;
//别的吸顶
static final int OTHER_STICKY_VIEW = 2;
//正常View
static final int NORMAL_VIEW = 3;
private final LayoutInflater mInflate;
private final List
StickyAdapter(Context context, List
mInflate = LayoutInflater.from(context);
this.datas = datas;
}
@Override
public RecyclerViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View inflate = mInflate.inflate(R.layout.item_ui, parent, false);
return new RecyclerViewHolder(inflate);
}
@Override
public void onBindViewHolder(RecyclerViewHolder holder, int position) {
Bean stickyBean = datas.get(position);
holder.tvName.setText(stickyBean.name);
if (position == 0) {
holder.tvStickyHeader.setVisibility(View.VISIBLE);
holder.tvStickyHeader.setText(stickyBean.sticky);
holder.itemView.setTag(FIRST_STICKY_VIEW);
} else {
if (!TextUtils.equals(stickyBean.sticky, datas.get(position - 1).sticky)) {
holder.tvStickyHeader.setVisibility(View.VISIBLE);
holder.tvStickyHeader.setText(stickyBean.sticky);
holder.itemView.setTag(OTHER_STICKY_VIEW);
} else {
holder.tvStickyHeader.setVisibility(View.GONE);
holder.itemView.setTag(NORMAL_VIEW);
}
}
//通过此处设置ContentDescription,作为内容描述,可以通过getContentDescription取出,功效跟setTag差不多。
holder.itemView.setContentDescription(stickyBean.sticky);
}
@Override
public int getItemCount() {
return datas == null ? 0 : datas.size();
}
public class RecyclerViewHolder extends RecyclerView.ViewHolder{
TextView tvStickyHeader;
RelativeLayout rlContentWrapper;
TextView tvName;
RecyclerViewHolder(View itemView) {
super(itemView);
tvStickyHeader = (TextView) itemView.findViewById(R.id.tv_sticky_header_view);
rlContentWrapper = (RelativeLayout) itemView.findViewById(R.id.rl_content_wrapper);
tvName = (TextView) itemView.findViewById(R.id.name);
}
}
}
public class Bean {
public String name;
public String sticky;
public Bean(String sticky, String name) {
this.sticky = sticky;
this.name = name;
}
}
xml 布局
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_10"
>
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:scrollbars="vertical" />
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#EFFAE7"
android:gravity="center"
android:text="@string/hello_blank_fragment" />
注意看 onBindViewHolder() 中,我们根据 sticky 值不一样,说明到了不同的文案的交错点,所以要显示出头部文案。如果此时要加一个粘性头部,就像微信ios版通讯录的效果,此时添加滑动监听机制
Activity 中布局
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/color_10"
>
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:scrollbars="vertical" />
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#EFFAE7"
android:gravity="center"
android:text="@string/hello_blank_fragment" />
mSuspensionBar 是 @+id/tv_suspensionbar; mRecyclerView 是 @+id/feed_list
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
View stickview = recyclerView.findChildViewUnder(0, 0);
if (stickview != null && stickview.getContentDescription() != null) {
if (!TextUtils.equals(mSuspensionBar.getText(), stickview.getContentDescription())) {
mSuspensionBar.setText(stickview.getContentDescription());
}
}
View transInfoView = recyclerView.findChildViewUnder(0, mSuspensionBar.getHeight() + 1);
if (transInfoView.getTag() != null) {
int transViewStatus = (int) transInfoView.getTag();
int top = transInfoView.getTop();
if (transViewStatus == StickyAdapter.OTHER_STICKY_VIEW) {
if (top > 0) {
int dealtY = top - mSuspensionBar.getMeasuredHeight();
mSuspensionBar.setTranslationY(dealtY);
} else {
mSuspensionBar.setTranslationY(0);
}
} else {
mSuspensionBar.setTranslationY(0);
}
}
}
});
View stickview = recyclerView.findChildViewUnder(0, 0) 找到的最上面的item, View transInfoView = recyclerView.findChildViewUnder(0, mSuspensionBar.getHeight() + 1) 找到的是在 mSuspensionBar 这个控件下面1像素的地方所在的item,所以就有了下面的逻辑:一、如果列表position在15之前,transViewStatus 为 FIRST_STICKY_VIEW 或 NORMAL_VIEW,此时 mSuspensionBar 待在原始位置即可;二、position到了15,transInfoView 对应的 position 为 15 时,说明此时两个头部布局要接壤了,此时需要下面的把上面的给顶上去,怎么顶?计算距离,让 mSuspensionBar 随着RecyclerView列表的滑动向上移动,所以有了 int top = transInfoView.getTop(),此时 int dealtY = top - mSuspensionBar.getMeasuredHeight()算出了位移;如果 top 小于0,说明该item已经向上滑出了 RecyclerView 的范围,所以就不用管了,把 mSuspensionBar 复原;三、接着又是类似一的逻辑,然后是二,就这样循环到结束。
上述如果使用 ItemDecoration 怎么实现呢?换一种思路和对象集合,我们先把对象在集合中分好组,添加属性来标识位置,
class GroupInfo {
private String content;
private String mTitle;
private boolean isFirstViewInGroup;
private boolean isLastViewInGroup;
public GroupInfo(String content, String title) {
this.content = content;
this.mTitle = title;
}
public String getContent() {
return content;
}
public String getTitle() {
return mTitle;
}
public void setFirstViewInGroup(boolean firstViewInGroup) {
isFirstViewInGroup = firstViewInGroup;
}
public void setLastViewInGroup(boolean lastViewInGroup) {
isLastViewInGroup = lastViewInGroup;
}
public boolean isFirstViewInGroup () {
return isFirstViewInGroup;
}
public boolean isLastViewInGroup () {
return isLastViewInGroup;
}
}
Activity 中代码
List
mRecyclerView.setAdapter(new TestAdapter(list));
mRecyclerView.addItemDecoration(new SectionDecoration(context, list));
适配器
class TestAdapter extends RecyclerView.Adapter
private final List
TestAdapter(List
this.datas = datas;
}
@Override
public TestHolder onCreateViewHolder(ViewGroup parent, int viewType) {
TextView tv = new TextView(parent.getContext());
tv.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 120));
tv.setGravity(Gravity.CENTER_VERTICAL);
TestHolder holder = new TestHolder(tv);
return holder;
}
@Override
public void onBindViewHolder(TestHolder holder, int position) {
if (datas != null && datas.size() > 0 ) {
String text = datas.get(position).getContent();
holder.tvName.setText(text);
}
}
@Override
public int getItemCount() {
return datas == null ? 0 : datas.size();
}
public class TestHolder extends RecyclerView.ViewHolder{
TextView tvName;
TestHolder(View itemView) {
super(itemView);
tvName = (TextView) itemView;
}
}
}
重点来了,
class SectionDecoration extends RecyclerView.ItemDecoration {
private List
private int mHeaderHeight;
private int mDividerHeight;
//用来绘制Header上的文字
private TextPaint mTextPaint;
private Paint mPaint;
private float mTextSize;
private Paint.FontMetrics mFontMetrics;
public SectionDecoration(Context context, List
this.mList = list;
mDividerHeight = context.getResources().getDimensionPixelOffset(R.dimen.header_divider_height);
mHeaderHeight = context.getResources().getDimensionPixelOffset(R.dimen.header_height);
mTextSize = context.getResources().getDimensionPixelOffset(R.dimen.header_textsize);
mHeaderHeight = (int) Math.max(mHeaderHeight,mTextSize);
mTextPaint = new TextPaint();
mTextPaint.setColor(Color.BLACK);
mTextPaint.setTextSize(mTextSize);
mFontMetrics = mTextPaint.getFontMetrics();
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(Color.YELLOW);
}
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
super.getItemOffsets(outRect, view, parent, state);
int position = parent.getChildAdapterPosition(view);
if ( mList != null ) {
GroupInfo groupInfo = mList.get(position);
//如果是组内的第一个则将间距撑开为一个Header的高度,或者就是普通的分割线高度
if ( groupInfo != null && groupInfo.isFirstViewInGroup() ) {
outRect.top = mHeaderHeight;
} else {
outRect.top = mDividerHeight;
}
}
}
@Override
public void onDraw(Canvas c, RecyclerView parent, 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 index = parent.getChildAdapterPosition(view);
if ( mList != null ) {
GroupInfo groupinfo = mList.get(index);
//只有组内的第一个ItemView之上才绘制
if ( groupinfo.isFirstViewInGroup() ) {
int left = parent.getPaddingLeft();
int top = view.getTop() - mHeaderHeight;
int right = parent.getWidth() - parent.getPaddingRight();
int bottom = view.getTop();
//绘制Header
c.drawRect(left,top,right,bottom,mPaint);
float titleX = left;
float titleY = bottom - mFontMetrics.descent;
//绘制Title
c.drawText(groupinfo.getTitle(),titleX,titleY,mTextPaint);
}
}
}
}
}
这样,我们在 getItemOffsets() 中计算出item之间的间隙,然后在 onDraw() 中把头部画出来。如果想画出粘性头部布局呢?我们此时重写 onDrawOver() 方法,
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
View view = parent.getChildAt(0);
int index = parent.getChildAdapterPosition(view);
if (mList != null) {
GroupInfo groupinfo = mList.get(index);
int left = parent.getPaddingLeft();
int right = parent.getWidth() - parent.getPaddingRight();
int top = parent.getPaddingTop();
if (groupinfo.isLastViewInGroup()) {
int suggestTop = view.getBottom() - mHeaderHeight;
if (suggestTop < top) {
top = suggestTop;
}
}
int bottom = top + mHeaderHeight;
drawHeaderRect(c, groupinfo, left, top, right, bottom);
}
}
private void drawHeaderRect(Canvas c, GroupInfo groupinfo, int left, int top, int right, int bottom) {
//绘制Header
c.drawRect(left,top,right,bottom,mPaint);
float titleX = left + mTextOffsetX;
float titleY = bottom - mFontMetrics.descent;
//绘制Title
c.drawText(groupinfo.getTitle(),titleX,titleY,mTextPaint);
}
这个里面的原理与方法一种的滑动监听的原理相似,这里是直接获取RecyclerView中首个item的view,然后获取到位置position。注意,此时不同组的item是有间隔的,正常情况下,我们绘制最上层的粘性头部时,把它固定在顶部即可,即 top 为 parent.getPaddingTop(),bottom 为 top + mHeaderHeight,然后通过 Canvas 把它画出来。最关键的一点就是两个粘性头部接壤了,此时需要把上面一个顶上,如果RecyclerView中首个item的底部到父容器的距离小于了 mHeaderHeight,说明两个头部接壤了,同理,算出它们的距离,然后算出 top 的值,理论上和方法一同样的原理。但此时这里需要注意一点,就是 Adapter 中的item的高度,要大于粘性头部布局的高度。