Android ItemDecoration 实现分组索引列表

0
1

我们要实现的效果主要涉及三个部分:

  • 分组 GroupHeader
  • 分割线
  • SideBar

前两个部分涉及到一个ItemDecoration类,也是我们接下来的重点,该类是RecyclerView的一个抽象静态内部类,主要作用就是给RecyclerViewItemView绘制额外的装饰效果,例如给RecyclerView添加分割线。

使用ItemDecoration时需要继承该类,根据需求可以重写如下三个方法,其它的方法已经deprecated了:

public class GroupHeaderItemDecoration extends RecyclerView.ItemDecoration {
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    }

    @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);
    }
}

然后将其添加到RecyclerView中:

recyclerView.addItemDecoration(new GroupHeaderItemDecoration())

了解这个三个方法的作用,这样才能更好的实现我们想要的功能:

1、getItemOffsets()

给指定的ItemView设置偏移量,具体怎么设置呢,咱们看图说话:

2

图中左边的是原始RecyclerView列表,右边是设置了ItemView偏移量的列表,其实相当于在ItemView外部添加了一个矩形区域
其中lefttoprightbottom就是ItemView在四个方向的偏移量,对应的设置代码如下:

outRect.set(left, top, right, bottom)

在我们的分组索引列表中,只需要对ItemView设置顶部的偏移量,其它三个偏移量为0即可。这样就可以在ItemView顶部预留出一定高度的区域,如下图:

3

2、onDraw()

getItemOffsets()方法中,我们设置了偏移量,进而得到了对应的偏移区域,接下来在onDraw()中就可以给ItemView绘制装饰效果了,所以我们在该方法中将分组索引列表中的GroupHeader的内容绘制在ItemView顶部偏移区域里。也就是绘制前边 gif 图里的A、B、C... GroupHeader,虽然看起来像一个个独立的ItemView,但并不是的哦!

注意该绘制操作会在ItemViewonDraw()前完成的!

3、onDrawOver()

该方法同样也是用来绘制的,但是它在ItemDecorationonDraw()方法和ItemViewonDraw()完成后才执行。所以其绘制的内容会遮挡在RecyclerView上,因此我们可以在该方法中绘制分组索引列表中悬浮的GroupHeader,也就是在列表顶部随着列表滚动切换的GroupHeader

一、分组GroupHeader

三个方法的作用已经解释完了,接下来就是代码实现我们的效果了:

首先保证RecyclerView的数据源已经按照某种规律进行了分组排序,具体什么规律你说了算,我们例子中按照数据源中指定字段的值的首字母升序排列,也就是常见通讯录的排序方式。然后在每个data中保存需要在GroupHeader上显示的内容,可以使用tag字段,我们这里保存的是对应的首字母。这里没必要将整个数据源设置到ItemDecoration里边,所以我们只需要提取排序后数据源的tag保存到列表中,然后设置到ItemDecoration里边,后边的操作就依赖设置的数据源了,根据tag的异同来决定是否绘制GroupHeader等。

上边已经分析了,GroupHeader只在列表中每组数据对应的第一个ItemView顶部显示,只需要对ItemView设置顶部的偏移量即可:

public class GroupHeaderItemDecoration extends RecyclerView.ItemDecoration {
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        RecyclerView.LayoutManager manager = parent.getLayoutManager();

        //只处理线性垂直类型的列表
        if ((manager instanceof LinearLayoutManager)
                && LinearLayoutManager.VERTICAL != ((LinearLayoutManager) manager).getOrientation()) {
            return;
        }

        int position = parent.getChildAdapterPosition(view);
        //ItemView的position==0 或者 当前ItemView的data的tag和上一个ItemView的不相等,则为当前ItemView设置top 偏移量
        if (!Utils.listIsEmpty(tags) && (position == 0 || !tags.get(position).equals(tags.get(position - 1)))) {
            outRect.set(0, groupHeaderHeight, 0, 0);
        }
    }

    @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);
    }
}

其中tags就是我们设置到ItemDecoration的数据源,是一个String集合。groupHeaderHeight就是ItemView的顶部偏移量。

之后就是在ItemView的顶部偏移区域绘制GroupHeader了:

public class GroupHeaderItemDecoration extends RecyclerView.ItemDecoration {
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        for (int i = 0; i < parent.getChildCount(); i++) {
            View view = parent.getChildAt(i);
            int position = parent.getChildAdapterPosition(view);
            String tag = tags.get(position);
            //和getItemOffsets()里的条件判断类似,开始绘制分组的GroupHeader
            if (!Utils.listIsEmpty(tags) && (position == 0 || !tag.equals(tags.get(position - 1)))) {
                drawGroupHeader(c, parent, view, tag);
            }
        }
    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
    }

    private void drawGroupHeader(Canvas c, RecyclerView parent, View view, String tag) {
        RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        int left = parent.getPaddingLeft();
        int right = parent.getWidth() - parent.getPaddingRight();
        int bottom = view.getTop() - params.topMargin;
        int top = bottom - groupHeaderHeight;
        c.drawRect(left, top, right, bottom, mPaint);
        int x = left + groupHeaderLeftPadding;
        int y = top + (groupHeaderHeight + Utils.getTextHeight(mTextPaint, tag)) / 2;
        c.drawText(tag, x, y, mTextPaint);
    }
}

绘制GroupHeader就是Canvasc操作,先绘制一个矩形框,再绘制相应的文字,当然绘制图片也是没问题的,其中groupHeaderLeftPadding是个可配置字段,代表绘制的文字或图片到列表左边沿的距离,也可以理解为GroupHeader的左padding

最后就是悬浮在顶部的GroupHeader绘制了:

public class GroupHeaderItemDecoration extends RecyclerView.ItemDecoration {
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    }

    @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);
        if (!show) {
            return;
        }
        //列表第一个可见的ItemView位置
        int position = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();
        String tag = tags.get(position);
        View view = parent.findViewHolderForAdapterPosition(position).itemView;
        //当前ItemView的data的tag和下一个itemView的不相等,则代表将要重新绘制悬停的GroupHeader
        boolean flag = false;
        if (!Utils.listIsEmpty(tags) && (position + 1) < tags.size() && !tag.equals(tags.get(position + 1))) {
            //如果第一个可见ItemView的底部坐标小于groupHeaderHeight,则执行Canvas向上位移操作
            if (view.getBottom() <= groupHeaderHeight) {
                c.save();
                flag = true;
                c.translate(0, view.getHeight() + view.getTop() - groupHeaderHeight);
            }
        }

        drawSuspensionGroupHeader(c, parent, tag);

        if (flag) {
            c.restore();
        }
    }

    private void drawSuspensionGroupHeader(Canvas c, RecyclerView parent, String tag) {
        int left = parent.getPaddingLeft();
        int right = parent.getWidth() - parent.getPaddingRight();
        int bottom = groupHeaderHeight;
        int top = 0;
        c.drawRect(left, top, right, bottom, mPaint);
        int x = left + groupHeaderLeftPadding;
        int y = top + (groupHeaderHeight + Utils.getTextHeight(mTextPaint, tag)) / 2;
        c.drawText(tag, x, y, mTextPaint);
    }
}

绘制操作和onDraw中的类似,gif 中有一个悬浮GroupHeader上移的动画,就是通过Canvas位移来实现的,注意在Canvas位移的前后进行save()restore()操作。

我们给GroupHeaderItemDecoration提供了设置GroupHeader左padding、高度、背景色、文字颜色、尺寸、以及是否显示顶部悬浮GroupHeader的方法,方便使用。

关于绘制操作需要注意的是,GroupHeader所在的偏移区域和ItemView是相互独立的,不要把GroupHeader当做ItemView的一部分哦。到这里GroupHeader的功能就实现了,只需要将GroupHeaderItemDecoration添加到RecyclerView即可。

至于如何通过layout或者View来实现GroupHeader,做过一些尝试,效果都不理想,期待大家的好想法哦!

这里先用一个接口,对外提供自定义绘制GroupHeader的方法:

public interface OnDrawItemDecorationListener {
    /**
     * 绘制GroupHeader 
     * @param c
     * @param paint 绘制GroupHeader区域的paint
     * @param textPaint 绘制文字的paint
     * @param params    共四个值left、top、right、bottom 代表GroupHeader所在区域的四个坐标值
     * @param position  原始数据源中的position
     */
    void onDrawGroupHeader(Canvas c, Paint paint, TextPaint textPaint, int[] params, int position);
     /**
      * 绘制悬浮在列表顶部的GroupHeader 
      */
    void onDrawSuspensionGroupHeader(Canvas c, Paint paint, TextPaint textPaint, int[] params, int position);
}

二、分割线

现在RecyclerView还差一个分割线,当前最笨的办法可以在ItemView的布局文件中设置,既然系统都提供了ItemDecoration,那用它来优雅的实现为何不可呢,我们只需要给列表中每组数据除了最后一项数据对应的ItemView之外的添加分割线即可,也就是不给每组数据对应的最后一个ItemView添加分割线。很简单,直接上核心代码:

public class DivideItemDecoration extends RecyclerView.ItemDecoration {
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        RecyclerView.LayoutManager manager = parent.getLayoutManager();

        //只处理线性垂直类型的列表
        if ((manager instanceof LinearLayoutManager)
                && LinearLayoutManager.VERTICAL != ((LinearLayoutManager) manager).getOrientation()) {
            return;
        }

        int position = parent.getChildAdapterPosition(view);
        if (!Utils.listIsEmpty(tags) && (position + 1) < tags.size() && tags.get(position).equals(tags.get(position + 1))) {
            //当前ItemView的data的tag和下一个ItemView的不相等,则为当前ItemView设置bottom 偏移量
            outRect.set(0, 0, 0, divideHeight);
        }
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        for (int i = 0; i < parent.getChildCount(); i++) {
            View view = parent.getChildAt(i);
            int position = parent.getChildAdapterPosition(view);
            //和getItemOffsets()里的条件判断类似
            if (!Utils.listIsEmpty(tags) && (position + 1) < tags.size() && tags.get(position).equals(tags.get(position + 1))) {
                drawDivide(c, parent, view);
            }
        }
    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
    }

    private void drawDivide(Canvas c, RecyclerView parent, View view) {
        RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        int left = parent.getPaddingLeft();
        int right = parent.getWidth();
        int top = view.getBottom() + params.bottomMargin;
        int bottom = top + divideHeight;
        c.drawRect(left, top, right, bottom, mPaint);
    }
}

三、SideBar

SideBar就是 gif 图右边的垂直字符条,是一个自定义View。手指触摸选中一个字符,则列表会滚动到对应的分组头部位置。实现起来也蛮简单的,核心代码如下:

public class SideBar extends View {
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        //重新计算SideBar宽高
        if (heightMode == MeasureSpec.AT_MOST || widthMode == MeasureSpec.AT_MOST) {
            getMaxTextSize();
            if (heightMode == MeasureSpec.AT_MOST) {
                heightSize = (maxHeight + 15) * indexArray.length;
            }

            if (widthMode == MeasureSpec.AT_MOST) {
                widthSize = maxWidth + 10;
            }
        }

        setMeasuredDimension(widthSize, heightSize);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        for (int i = 0; i < indexArray.length; i++) {
            String index = indexArray[i];
            float x = (mWidth - mTextPaint.measureText(index)) / 2;
            float y = mMarginTop + mHeight * i + (mHeight + Utils.getTextHeight(mTextPaint, index)) / 2;
            //绘制字符
            canvas.drawText(index, x, y, mTextPaint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                // 选中字符的下标
                int pos = (int) ((event.getY() - mMarginTop) / mHeight);
                if (pos == lastPos) {
                    return true;
                }
                if (pos >= 0 && pos < indexArray.length) {
                    lastPos = pos;
                    setBackgroundColor(TOUCH_COLOR);
                    if (onSideBarTouchListener != null) {
                        for (int i = 0; i < tags.size(); i++) {
                            if (indexArray[pos].equals(tags.get(i))) {
                                onSideBarTouchListener.onTouch(indexArray[pos], i);
                                break;
                            }
                            if (i == tags.size() - 1) {
                                onSideBarTouchListener.onTouch(indexArray[pos], -1);
                            }
                        }
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                lastPos = -1;
                setBackgroundColor(UNTOUCH_COLOR);
                if (onSideBarTouchListener != null) {
                    onSideBarTouchListener.onTouchEnd();
                }
                break;
        }

        return true;
    }
}

onMeasure()方法里,如果SideBar的宽、高测量模式为MeasureSpec.AT_MOST则重新计算SideBar的宽、高。onDraw()方法则是遍历索引数组,并绘制字符索引。在onTouchEvent()方法里,我们根据手指在SideBar上触摸坐标点的y值,计算出触摸的相应字符,以便在OnSideBarTouchListener接口进行后续操作,例如列表的跟随滚动等等。

四、实例

前边已经完成了三大核心功能,最后来愉快的使用下吧:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.list);
        SideBar sideBar = (SideBar) findViewById(R.id.side_bar);
        final TextView tip = (TextView) findViewById(R.id.tip);

        final List<ItemData> datas = new ArrayList<>();
        ItemData data = new ItemData("北京");
        datas.add(data);
        ItemData data1 = new ItemData("上海");
        datas.add(data1);
        ItemData data2 = new ItemData("广州");
        datas.add(data2);
        .
        .
        .
        ItemData data34 = new ItemData("Hello China");
        datas.add(data34);
        ItemData data35 = new ItemData("宁波");
        datas.add(data35);

        SortHelper<ItemData> sortHelper = new SortHelper<ItemData>() {
            @Override
            public String sortField(ItemData data) {
                return data.getTitle();
            }
        };
        sortHelper.sortByLetter(datas);//将数据源按指定字段首字母排序
        List<String> tags = sortHelper.getTags(datas);//提取已排序数据源的tag值

        MyAdapter adapter = new MyAdapter(this, datas, false);
        final LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
        recyclerView.setLayoutManager(layoutManager);
        //添加分割线
        recyclerView.addItemDecoration(new DivideItemDecoration().setTags(tags));
        //添加GroupHeader
        recyclerView.addItemDecoration(new GroupHeaderItemDecoration(this)
                .setTags(tags)//设置tag集合
                .setGroupHeaderHeight(30)//设置GroupHeader高度
                .setGroupHeaderLeftPadding(20));//设置GroupHeader 左padding
        recyclerView.setAdapter(adapter);

        sideBar.setOnSideBarTouchListener(tags, new OnSideBarTouchListener() {
            @Override
            public void onTouch(String text, int position) {
                tip.setVisibility(View.VISIBLE);
                tip.setText(text);
                if ("↑".equals(text)) {
                    layoutManager.scrollToPositionWithOffset(0, 0);
                    return;
                }
                //滚动列表到指定位置
                if (position != -1) {
                    layoutManager.scrollToPositionWithOffset(position, 0);
                }
            }

            @Override
            public void onTouchEnd() {
                tip.setVisibility(View.GONE);
            }
        });
    }
}

这也就是文章开头的 gif 效果。如果需要自定义ItemView的绘制可以这样写:

recyclerView.addItemDecoration(new GroupHeaderItemDecoration(this)
                .setTags(tags)
                .setGroupHeaderHeight(30)
                .setGroupHeaderLeftPadding(20)
                .setOnDrawItemDecorationListener(new OnDrawItemDecorationListener() {
                    @Override
                    public void onDrawGroupHeader(Canvas c, Paint paint, TextPaint textPaint, int[] params, int position) {
                        c.drawRect(params[0], params[1], params[2], params[3], paint);

                        int x = params[0] + Utils.dip2px(context, 20);
                        int y = params[1] + (Utils.dip2px(context, 30) + Utils.getTextHeight(textPaint, tags.get(position))) / 2;

                        Bitmap icon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher, null);
                        Bitmap icon1 = Bitmap.createScaledBitmap(icon, Utils.dip2px(context, 20), Utils.dip2px(context, 20), true);
                        c.drawBitmap(icon1, x, params[1] + Utils.dip2px(context, 5), paint);

                        c.drawText(tags.get(position), x + Utils.dip2px(context, 25), y, textPaint);
                    }

                    @Override
                    public void onDrawSuspensionGroupHeader(Canvas c, Paint paint, TextPaint textPaint, int[] params, int position) {
                        c.drawRect(params[0], params[1], params[2], params[3], paint);
                        int x = params[0] + Utils.dip2px(context, 20);
                        int y = params[1] + (Utils.dip2px(context, 30) + Utils.getTextHeight(textPaint, tags.get(position))) / 2;

                        Bitmap icon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher, null);
                        Bitmap icon1 = Bitmap.createScaledBitmap(icon, Utils.dip2px(context, 20), Utils.dip2px(context, 20), true);
                        c.drawBitmap(icon1, x, params[1] + Utils.dip2px(context, 5), paint);

                        c.drawText(tags.get(position), x + Utils.dip2px(context, 25), y, textPaint);
                    }
                })
        );

坐标计算有点复杂了......0_o......
看下效果:

4

当然不止于此,更多的效果等待着机智的你去创造。


更多代码细节及用法可参考https://github.com/SheHuan/GroupIndexLib

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

推荐阅读更多精彩内容