天天看点

正确使用RecyclerView分割线

RecyclerView是很强大的控件,基本可以替代ListView和GridView。但是RecyclerView没有封装一些listview的功能,例如分割线,item点击事件等等,需要自己实现。item点击事件在ViewHolder中设置Click事件就行了。实现分割线功能,则需要使用addItemDecoration添加一个自定义的分割线。

我使用的RecyclerView的版本是26.1.0,以下都是基于这个版本来讲的,先看一下ItemDecoration(item 装饰器)这个类的注释及方法。

/**
     * 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.
     *
     * <p>All ItemDecorations are drawn in the order they were added, before the item
     * views (in {@link ItemDecoration#onDraw(Canvas, RecyclerView, RecyclerView.State) onDraw()}
     * and after the items (in {@link ItemDecoration#onDrawOver(Canvas, RecyclerView,
     * RecyclerView.State)}.</p>
     */
    public abstract static class ItemDecoration {
        /**
         * Draw any appropriate decorations into the Canvas supplied to the RecyclerView.
         * Any content drawn by this method will be drawn before the item views are drawn,
         * and will thus appear underneath the views.
         *
         * @param c Canvas to draw into
         * @param parent RecyclerView this ItemDecoration is drawing into
         * @param state The current state of RecyclerView
         */
        public void onDraw(Canvas c, RecyclerView parent, State state) {
            onDraw(c, parent);
        }

        /**
         * @deprecated
         * Override {@link #onDraw(Canvas, RecyclerView, RecyclerView.State)}
         */
        @Deprecated
        public void onDraw(Canvas c, RecyclerView parent) {
        }

        /**
         * Draw any appropriate decorations into the Canvas supplied to the RecyclerView.
         * Any content drawn by this method will be drawn after the item views are drawn
         * and will thus appear over the views.
         *
         * @param c Canvas to draw into
         * @param parent RecyclerView this ItemDecoration is drawing into
         * @param state The current state of RecyclerView.
         */
        public void onDrawOver(Canvas c, RecyclerView parent, State state) {
            onDrawOver(c, parent);
        }

        /**
         * @deprecated
         * Override {@link #onDrawOver(Canvas, RecyclerView, RecyclerView.State)}
         */
        @Deprecated
        public void onDrawOver(Canvas c, RecyclerView parent) {
        }


        /**
         * @deprecated
         * Use {@link #getItemOffsets(Rect, View, RecyclerView, State)}
         */
        @Deprecated
        public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
            outRect.set(0, 0, 0, 0);
        }

        /**
         * Retrieve any offsets for the given item. Each field of <code>outRect</code> specifies
         * the number of pixels that the item view should be inset by, similar to padding or margin.
         * The default implementation sets the bounds of outRect to 0 and returns.
         *
         * <p>
         * If this ItemDecoration does not affect the positioning of item views, it should set
         * all four fields of <code>outRect</code> (left, top, right, bottom) to zero
         * before returning.
         *
         * <p>
         * If you need to access Adapter for additional data, you can call
         * {@link RecyclerView#getChildAdapterPosition(View)} to get the adapter position of the
         * View.
         *
         * @param outRect Rect to receive the output.
         * @param view    The child view to decorate
         * @param parent  RecyclerView this ItemDecoration is decorating
         * @param state   The current state of RecyclerView.
         */
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
            getItemOffsets(outRect, ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition(),
                    parent);
        }
    }
           

注释的大致意思:Item装饰器可以用来给item添加特殊的绘制图像和布局偏移(也就是padding)。可以实现分割线,高亮,视觉分组等等效果。3个方法的注释(3个过时方法对应3个不过时的方法不再讨论):

  1. onDraw:可以使用Canvas绘制各种装饰,在绘制视图之前触发,绘制的内容显示在视图下方(相当于背景)。
  2. onDrawOver:可以使用Canvas绘制各种装饰,在绘制视图之后触发,绘制的内容显示在视图上方。
  3. getItemOffsets:给outRect指定上下左右的边距大小,不指定默认为0,设置的大小就是item四周的padding。可以使用getChildAdapterPosition(View)来获取当前item的position。

关于onDraw,onDrawOver的使用可以参考hongyang大神的文章,这里主要讨论getItemOffsets设置边距。一般来说,设置分割线有以下几种,

  1. 左右相等,上下相等,每个item都设置的情况,不会导致item宽高不同,直接使用outRect.set(voffset, hoffset, voffset, hoffset)设置就可以了。
  2. 左右不同,上下不同,每个item都设置的情况,这时候也不会导致item的宽高不同,使用outRect.set(left, top, right, bottom)设置。
  3. 两边没有分割线中间有。这种情况常见于纵向的RecyclerView(GridLayoutManager,StaggeredGridLayoutManager),一般设置方式是:判断如果是第一列只设置右侧的间距,最后一列只设置左侧的间距,中间的设置左右两侧的间距。效果看上去是没问题的,但是实际上左右的两个item比较宽,宽了半个间距。
  4. 上下没有中间有,类似于3,上下两个item高了一个间距。
  5. 特殊的一种情况,左右或上下的分割线和中间的分割线相同。

先看正常的实现方式,代码如下

/**
     * 普通设置间距(纵向)
     */
    public class NormalItemDecoration extends RecyclerView.ItemDecoration {
        private int spanCount; // 每行个数
        private int decoration; // 间距
        private boolean includeEdge; // 是否需要左右,上下分割线

        public NormalItemDecoration(int spanCount, int decoration, boolean includeEdge) {
            this.spanCount = spanCount;
            this.decoration = decoration;
            this.includeEdge = includeEdge;
        }

        @Override
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                                   @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
            int position = parent.getChildAdapterPosition(view);
            int column = position % spanCount;// 计算这个child 处于第几列
            if (column % spanCount == 0) {
                // 第一列
                if (includeEdge) {
                    outRect.left = decoration;
                } else {
                    outRect.left = 0;
                }
                outRect.right = decoration / 2;
            } else if ((column + 1) % spanCount == 0) {
                // 最后一列
                outRect.left = decoration / 2;
                if (includeEdge) {
                    outRect.right = decoration;
                } else {
                    outRect.right = 0;
                }
            } else {
                outRect.left = decoration / 2;
                outRect.right = decoration / 2;
            }
            if (includeEdge) {
                if (position < spanCount) { // 第一行设置top
                    outRect.top = decoration;
                }
                outRect.bottom = decoration; // 设置bottom
            } else {
                if (position >= spanCount) {
                    outRect.top = decoration; // 非第一行设置top
                }
            }
        }
    }
           

看一下效果:无四周分割线时,左右的item会比较大,大了半个间隔。

正确使用RecyclerView分割线

有四周分割线时,左右的item比较小,小了一半的间隔。

正确使用RecyclerView分割线

当间距比较小时,肉眼看不出来,但是设置的间距比较大时就比较明显了。导致这个的原因就是间距就相当于padding,设置的padding不同,item的宽高就会不同。那么怎么解决呢,StackOverflow有这个解决方案,链接https://stackoverflow.com/a/30701422/4696538。以GridLayoutManager为例:间距设置为5dp,每行有4列,左右没有分割线,中间有分割线。第一列:左侧为0,右侧为4dp;第二列:左侧为1dp,右侧为3dp;第三列:左侧为2dp,右侧为2dp;第三列:左侧为3dp,右侧为1dp;第4列:左侧为4dp,右侧为0;这样就是分割线为5dp,而且宽度都相同。虽然如此,按照StackOverflow的解决方案来写的话也是会有一点儿小问题,因为间隔除以spanCount如果不是整数,可能导致item相差一两个像素。如下图

正确使用RecyclerView分割线

当然一两个像素,问题不算大,但是也可以解决,先计算出每个总的左右间距,每次只计算左边间距,右边间距则用总间距减去左侧获得,代码如下:

/**
     * 校准过的设置间距(纵向)
     */
    public class GridItemDecoration extends RecyclerView.ItemDecoration {
        private int spanCount; // 每行个数
        private int decoration; // 间距
        private boolean includeEdge; // 是否需要左右,上下分割线
        private int total; // item总共的间隔
        private int[] left; // left 数组
        private int[] right; // right 数组

        public GridItemDecoration(int spanCount, int decoration, boolean includeEdge) {
            this.spanCount = spanCount;
            this.decoration = decoration;
            this.includeEdge = includeEdge;
            if (includeEdge) {
                total = decoration + Math.round(decoration * 1f / spanCount);
            } else {
                total = Math.round(decoration * (spanCount - 1) * 1f / spanCount);
            }
            left = new int[spanCount];
            right = new int[spanCount];
            for (int i = 0; i < spanCount; i++) {
                if (i == 0) {
                    if (includeEdge) {
                        left[i] = decoration;
                    } else {
                        left[i] = 0;
                    }
                } else {
                    left[i] = decoration - (total - left[i - 1]); // 后一列的left = decoration - (total - 上一列的left)
                }
                right[i] = total - left[i];
            }
        }

        @Override
        public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
                                   @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
            int position = parent.getChildAdapterPosition(view);
            int column = position % spanCount;// 计算这个child 处于第几列
            outRect.left = left[column];
            outRect.right = right[column];

            if (includeEdge) {
                if (position < spanCount) { // 第一行设置top
                    outRect.top = decoration;
                }
                outRect.bottom = decoration; // 设置bottom
            } else {
                if (position >= spanCount) {
                    outRect.top = decoration; // 非第一行设置top
                }
            }
        }
    }
           

就是以加减代替除法,消除可能的精度问题。

效果如下

正确使用RecyclerView分割线
正确使用RecyclerView分割线

最后:纵向时top和bottom不会影响控件的高度。同理横向时就需要动态设置top和bottom。