RecyclerView#ItemDecoration入门与进阶

使用RecyclerView替代ListView已经是老生常谈的话题了,RecyclerView的优秀和灵活已经经过了大量项目的实践。最近在完成一个分组列表的需求时,使用到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允许应用结合adapter的数据集,对特定的item添加绘制一个周边图案。可以用于给items之间添加分割线、高亮装饰效果或者分组边界等等。

从谷歌官方的介绍可以知道,ItemDecoration是用于给列表的item添加各种装饰效果,开发中最常见的就是为item添加分割线。 ItemDecoration本身是一个抽象类,抛去废弃的方法,我们需要关心的方法只有三个:

public static abstract class ItemDecoration {
    public void onDraw(Canvas c, RecyclerView parent, State state) {
        onDraw(c, parent);
    }
    public void onDrawOver(Canvas c, RecyclerView parent, State state) {
        onDrawOver(c, parent);
    }
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
        getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),parent);
    }
}

从源码注释中,可以大概了解这三个方法的用途:

  • onDraw:在item绘制之前时被调用,将指定的内容绘制到item view内容之下;
  • onDrawOver:在item被绘制之后调用,将指定的内容绘制到item view内容之上
  • getItemOffsets:在每次测量item尺寸时被调用,将decoration的尺寸计算到item的尺寸中

ItemDecoration三个方法的测试

谷歌官方在support.v7包中提供了ItemDecoration的一个实现DividerItemDecoration,这里结合这个实现,来看看其三个需要实现的方法对UI的影响。

onDraw

    private void drawVertical(Canvas canvas, RecyclerView parent) {
        canvas.save();
        final int left;
        final int 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();
        }

        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(ViewCompat.getTranslationY(child));
            final int top = bottom - mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(canvas);
        }
        canvas.restore();
    }

drawVertical方法实现了对Orientation == VERTICAL的RecyclerView绘制item之间的分割线。从传入的canvas参数可以推断,分割线的绘制是通过canvas机制绘制到屏幕上:mDivider.draw(canvas);其中,mDivider是一个Drawable对象,可以通过setDrawable传入自定义对象,不传入时,会自动使用系统内置的分割线样式:android.R.attr.listDivider。通过遍历每一个可见的child view,计算mDivider对应的left、top、right、bottom值,从而绘制到正确的位置上。对于纵向的RecyclerView而言,mDivider的left和right是固定的,和parent的左右内容边界保持一致,也就是说,把parent的左右padding都计算进去,因而是代表了RecyclerView实际的内容区域。纵向的分割线一般位于每个item的底部,因此mDivider的top值理论上应该和child view的内容下边界保持贴合。实际上,计算top和bottom的代码,谷歌官方也有所调整,在最新的实现中,先通过parent.getDecoratedBoundsWithMargins(child, mBounds);拿到之前在onMeasure过程中,通过调用getItemOffsets获取到的mBounds,mBounds是包括了整个child view以及其decoration的总边界,之后再计算mDivider的bottom、top值。

getItemOffsets

    public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
            RecyclerView.State state) {
        if (mOrientation == VERTICAL) {
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }

官方实现的getItemOffsets比较简单,只是根据列表的方向,返回了分割线在相应方向的尺寸。这里可能有一个坑,即通过setDrawable设置自定义的分割线时,容易传入一个无尺寸的drawable对象,导致分割线无法显示出来的bug,典型的代码是这样: decoration.setDrawable(new ColorDrawable(Color.RED));

DividerItemDecoration的实现中,是没有复写onDrawOver方法的,对于分割线场景而言,也确实不需要去实现它。接下来,通过几个例子,展示一下getItemOffsets对于ItemDecoration在UI上的影响。

getItemOffsets & onDraw

先上动图【注2】:


上图中,getItemOffsets方法里,返回outRect不同,而onDraw方法绘制的分割线高度初始值设为25,并通过外部增减来观察其UI效果。

        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            outRect.set(0, 0, 0, 50);// outRect.set(50,50,50,50);
        }

        public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
            for (int i = 0; i < childCount; i++) {
                final View view = parent.getChildAt(i);
                top = view.getBottom();
                left = view.getPaddingLeft() + mSize;
                right = view.getWidth() - view.getPaddingRight() - mSize ;
                bottom = top + mSize;
                divider.setBounds(left, top, right, bottom);
                divider.draw(c);
            }
        }

从上面两个动图对比,可以得出以下几个结论:

  • getItemOffsets返回的矩形outRect会被计算到child view的尺寸当中;
  • onDraw方法绘制的图形,可以超出outRect所规定的区域;
  • onDraw方法绘制的图形,确实是处于child view的底下,当两者发生重叠时,只会显示child view的内容;

getItemOffsets & onDrawOver


将之前onDraw方法内代码完整拷贝到onDrawOver下,并注释掉之前onDraw中的方法,很容易验证出onDrawOver与onDraw的唯一不同之处。

  • onDrawOver绘制的图形,处于child view之上,当两者发生重叠时,会显示onDrawOver的内容;

ItemDecoration三个方法的含义,就介绍到这里。可以感觉到,三个方法都很简单而基础,可以十分优雅的实现item的分割线效果,然而简单的如DividerItemDecoration,往往是无法满足项目开发需求的。经常会遇到某几个item不想要分割线(如头部或者最后一个item),这就需要开发者自行来实现。

利用ItemDecoration实现分组列表效果

先看效果图:

上图展示了利用ItemDecoration实现分组栏的效果,对于分组效果,需要注意的点在于,如何确定分组栏位置和内容,如何实现分组栏吸顶效果(如果需要)。

  1. 分组栏位置一般是由外部决定,常见是根据数据源list中某个特征值来决定,比较好的做法是通过接口来实现。

public interface IHover {

    /**
     * 当前position是否需要绘制分组栏
     * @param position 当前位置
     * @return true表示需要绘制
     */
    boolean isGroup(int position);


    /**
     * 当前位置需要绘制的文本
     * @param position 当前位置
     * @return String
     */
    String groupText(int position);
}
  1. 分组栏效果实际上是利用了onDrawOver和onDraw方法,onDraw方法负责绘制每一个需要分组的Decoration,而onDrawOver方法只绘制最顶部item的Decoration,由于onDrawOver绘制的内容永远会显示在最顶层,因此,实际上是,每一个顶部item都绘制了一个Decoration,但是相同分组的Decoration内容和位置一摸一样,就导致看上去是一直吸顶的效果。部分代码如下: ```
  1. onDraw:

         if (builder.iHover.isGroup(position)) {
             bottom = childView.getTop();
             top = bottom - builder.decorationHeight;
             mDivider.setBounds(left, top, right, bottom);
             mDivider.draw(c);
             String text = builder.iHover.groupText(position);
             if (!TextUtils.isEmpty(text)) {
                 Paint.FontMetrics fm = textPaint.getFontMetrics();
                 //文字竖直居中显示
                 float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
                 int textLeft = left;
                 float textWidth = textPaint.measureText(text, 0, text.length());
                 if (builder.textAlign == Builder.ALIGN_MIDDLE) {
                     textLeft  = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
                 }
                 c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
             }
         }
    

getItemOffsets:

        // 分组模式只在分组时才绘制
        if (builder.iHover.isGroup(pos)) {
            outRect.set(0, builder.decorationHeight, 0, 0);
        }

onDrawOver:

    // 只有需要分组功能时,才走以下逻辑
    if (builder.iHover != null) {
        int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();

        int bottom, top;
        int left = parent.getPaddingLeft();
        int right = parent.getWidth() - parent.getPaddingRight();

        top = parent.getPaddingTop();
        bottom = top + builder.decorationHeight;
        mDivider.setBounds(left, top, right, bottom);
        mDivider.draw(c);
        String text = builder.iHover.groupText(position);
        if (!TextUtils.isEmpty(text)) {
            Paint.FontMetrics fm = textPaint.getFontMetrics();
            //文字竖直居中显示
            float baseLine = bottom - (builder.decorationHeight - (fm.bottom - fm.top)) / 2 - fm.bottom;
            int textLeft = left;

            float textWidth = textPaint.measureText(text, 0, text.length());
            if (builder.textAlign == Builder.ALIGN_MIDDLE) {
                textLeft  = (int) (parent.getPaddingLeft() + parent.getWidth()/2 - textWidth/2);
            }
            c.drawText(text, textLeft + builder.textLeftPadding, baseLine, textPaint);
        }
    }


实际项目需求的封装类MKItemDecoration

  • 支持简单颜色作为分割线
  • 支持简单颜色分割线 + 文字作为分组栏:文字可以居左、居中、居右
  • 支持分割线跳过起始诺干个item
  • 支持分组悬停效果
  • 支持自定义View作为分组栏,

典型的使用代码如下:

        recyclerView.addItemDecoration(new MKItemDecoration.Builder()
                .height(50)
                .color(Color.parseColor("#525D97"))
                .textSize(30)
                .textColor(Color.WHITE)
                .itemOffset(0)
                .iHover(new IHover() {
                    @Override
                    public boolean isGroup(int position) {
                        return position % 4 == 0;
                    }

                    @Override
                    public String groupText(int position) {
                        return adapter.data.get(4 * (position / 4));
                    }
                }).
                .textAlign(MKItemDecoration.Builder.ALIGN_MIDDLE)
                .build());

总结

通过封装,利用builder模式来更好的自定义需要的Decoration,其中,为了支持自定义View,需要外部传入相关的view的资源id和需要绑定的数据List,控件内部会通过view的measure,layout,draw的流程,将其绘制在屏幕上。同时,由于所有的图形都是通过canvas绘制到屏幕上,因此不能响应用户的操作,也就是无法设置点击事件,一个可行的方案是,记录每个Decoration的位置,通过判断手指点击区域来代理RecyclerView的触摸事件,从而实现点击效果,目前暂未实现。

具体代码见:https://github.com/Dragon-Boat/library

感谢:

  1. https://blog.piasy.com/2016/03/26/Insight-Android-RecyclerView-ItemDecoration/
  2. https://github.com/fishyer/PinnedRecyclerView

注1:图片引用自该文章链接。 注2:动图使用Vysor+GifCam录制,前者将手机屏幕内容投射到电脑上,后者录制git图片。

本文来自网易实践者社区,经作者周龙授权发布。