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的尺寸中
绘制顺序[注1]

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】:


outRect(0,0,0,50).gif

outRect(50,50,50,50).gif

上图中,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

outRect(50,50,50,50).gif

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

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

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

利用ItemDecoration实现分组列表效果

先看效果图:

hoverGroup.gif

上图展示了利用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内容和位置一摸一样,就导致看上去是一直吸顶的效果。部分代码如下:
#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,跳过最后一个item
  • 支持分组悬停效果
  • 支持自定义View作为Decoration


    customView.gif

上图hoverGroup.gif的使用代码如下:

        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的流程,将其绘制在屏幕上。

具体代码见:https://github.com/Dragon-Boat/library
欢迎提issue 和 star~

TODO:

  • itemDecoration是通过draw绘制图形,不支持点击事件

感谢:

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容