ItemDecoration解析(一) getItemOffsets

/ Android功能Case / 没有评论 / 479浏览

引言

N年前在简书上写了ItemDecoration系列文章, 写完也没维护了, 最近无意间发现百度搜索getItemOffsets,第一篇就是ItemDecoration解析(一) getItemOffsets ,这篇文章访问量也不小,有1W+的阅读量.本着负责任的原则,避免新入坑的小伙伴被里面的纰漏误导,所以决定重新梳理下.

ItemDecoration介绍

ListView切换到RecyclerView的小伙伴应该深有感受RecyclerView为解耦做出的努力.具体体现是RecyclerView将部分职责放到了五虎将上:RecyclerView.LayoutManager,RecyclerView.Adapter,RecyclerView.ViewHolder,RecyclerView.ItemDecoration,RecyclerView.ItemAnimator.

他们各司其职,有各自的作用,挑一个我觉得最有趣的来说:

RecyclerView.LayoutManager负责Item视图的布局的显示管理,LayoutManager承载了部分ViewGroupmeasurelayout的职责,这也是我们最常用的,Google给我们提供了LinearLayoutManager,GridLayoutManager,StaggeredGridLayoutManager三种默认的LayoutManager.还有个FlexboxLayoutManager可以通过添加额外的依赖引入,具体参见github地址

当然还有我们这篇文章要讲的RecyclerView.ItemDecoration.从类名直译过来是"条目装饰",Google对它的介绍如下:

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给指定的ItemView添加绘图和布局偏移,这对于在项目中给items间绘制分隔线,突出显示,可视化分组边界是非常有用的.

ItemDecoration是个抽象类,排除被标记为过时的方法后只剩下3个方法

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

其中getItemOffsets方法就是开发文档中提到的起到layout offset作用: 为每一个item设置偏移,说得更通俗点就是给每一个itemView设置margin.我们可以通过分析源码来证明我们的观点.

源码分析

我们都知道ViewGroup会通过onMeasureonLayout来控制子View的宽高和子View的在父ViewGroup中的位置,RecyclerView也不例外,只不过它将主要逻辑都放到了LayoutManager中,RecylerViewonMeasureonLayout的时候,最终都会调用layoutMangeronLayoutChildren方法.

因此肯定onLayoutChildren既负责计算子View的宽高,也负责控制子View在父ViewGroup中的位置. 由于不同LayoutManageronLayoutChildren的实现不同,为了方便分析,我们直接看LinearLayoutManageronLayoutChildren, 通过层层调用onLayoutChildren->fill->layoutChunk,最终会调用关键方法layoutChunk,精简源码如下:

    void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,
            LayoutState layoutState, LayoutChunkResult result) {
        View view = layoutState.next(recycler);
        
        RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
        // 关键方法1
        measureChildWithMargins(view, 0, 0);
        result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
        // 通过mOrientationHelper确定itemView的left top right bottom
        int left, top, right, bottom;
        // mOrientation 为 VERTICAL时
        if (mOrientation == VERTICAL) {
        
            left = getPaddingLeft();
            right = left + mOrientationHelper.getDecoratedMeasurementInOther(view);
            
            if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
                bottom = layoutState.mOffset;
                top = layoutState.mOffset - result.mConsumed;
            } else {
                top = layoutState.mOffset;
                bottom = layoutState.mOffset + result.mConsumed;
            }
        } else {
            // mOrientation为horizontal时逻辑与VERTICAL差不多, left right 与top bottom 赋值逻辑对调
        }
        // 关键方法2
        layoutDecoratedWithMargins(view, left, top, right, bottom);

    }

layoutChunk方法中有两处关键方法:

  1. 关键方法1 measureChildWithMargins
  2. 关键方法2 layoutDecoratedWithMargins

measureChildWithMargins

从方法命名就能大致猜出:measureChildWithMargins负责计算子View宽高,layoutDecoratedWithMargins负责控制子View的位置, 我们逐个击破,先看看measureChildWithMargins方法

        public void measureChildWithMargins(@NonNull View child, int widthUsed, int heightUsed) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();

            final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
            widthUsed += insets.left + insets.right;
            heightUsed += insets.top + insets.bottom;

            final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
                    getPaddingLeft() + getPaddingRight()
                            + lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
                    canScrollHorizontally());
            final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
                    getPaddingTop() + getPaddingBottom()
                            + lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
                    canScrollVertically());
            if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
                child.measure(widthSpec, heightSpec);
            }
        }

做过自定义View的同学应该一样就能看出,这里通过父ViewGroup的宽高,父ViewGroupPadding,子ViewMargin,子View在布局文件中的layout_width,layout_height以及getItemDecorInsetsForChild方法获取一个Rect类型的insert,并计算widthUsed,heightUsed来综合确定View的宽高.来个差不多的示意图,这里的insert就类似子View的又一层margin

示意图

我们继续分析getItemDecorInsetsForChild方法来看看这个insert到底是什么东西,

    Rect getItemDecorInsetsForChild(View child) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        if (!lp.mInsetsDirty) {
            return lp.mDecorInsets;
        }

        if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
            // changed/invalid items should not be updated until they are rebound.
            return lp.mDecorInsets;
        }
        final Rect insets = lp.mDecorInsets;
        insets.set(0, 0, 0, 0);
        final int decorCount = mItemDecorations.size();
        for (int i = 0; i < decorCount; i++) {
            mTempRect.set(0, 0, 0, 0);
            mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);
            insets.left += mTempRect.left;
            insets.top += mTempRect.top;
            insets.right += mTempRect.right;
            insets.bottom += mTempRect.bottom;
        }
        lp.mInsetsDirty = false;
        return insets;
    }

总算看到ItemDecoration的影子了,是不是有种豁然开朗的感觉,遍历RecyclerViewItemDecoration,通过ItemDecorationgetItemOffsets累加获得一个Rect类型的insets, 并且赋值给子View layoutParamsmDecorInsets字段.

这里有两个重要信息

  1. ItemDecoration是个集合, 最终结果是ItemDecoration累加决定的
  2. 累加得到的insets会赋值给子View layoutParamsmDecorInsets字段,(子View布局的时候会用到)

到这里子View的宽高计算就分析完了 我们小结下

  1. RecyclerViewonMeasure方法负责子View宽高的计算, 但最终由LayouManageronLayoutChildren控制.
  2. 不同的LayouManageronLayoutChildren有不同的实现,但对ItemDecoration的处理都一样.
  3. 对于LinearLayoutMnager,onLayoutChildren最终会调用layoutChunk,里面有计算子View宽高的逻辑
  4. layoutChunk中的measureChildWithMargins负责计算子View的宽高.View的宽高由父ViewGroup的宽高,父ViewGroupPadding,子ViewMargin,子View在布局文件中的layout_width,layout_height以及getItemDecorInsetsForChild方法获取一个Rect类型的insert,综合决定.
  5. RecyclerViewgetItemDecorInsetsForChild方法会遍历ItemDecoration,并调用ItemDecorationgetItemOffset方法,累加得到一个insert,并赋值给子View``LayoutParamsmDecorInsets字段.
  6. insert可以理解为ItemView额外的margin

layoutDecoratedWithMargins

View的宽高计算完了,接下来就是子View在父ViewGroup中的位置计算了,我们来看看关键方法2

        public void layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
                int bottom) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            final Rect insets = lp.mDecorInsets;
            child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
                    right - insets.right - lp.rightMargin,
                    bottom - insets.bottom - lp.bottomMargin);
        }

很简单, 而且我们刚刚说的mDecorInsets字段也参与了计算, 它的作用和layoutParams lp中的margin一样.

篇幅有限, 这里我们省略了left,top,right,bottom赋值的分析, 这里简单说下, LinearLayoutManager会根据orientation创建mOrientationHelper,mOrientationHelper又会根据orientation实现抽象方法,拿两个方法举下例子:

  1. getDecoratedMeasurement表示子Vieworientation同方向上的大小,orientationvertical时,计算子ViewgetDecoratedMeasuredHeighttopMargin,bottomMargin
  2. getDecoratedMeasurementInOther表示与orientation垂直方向上的大小,orientationvertical时,计算子ViewgetDecoratedMeasuredWidthleftMargin,rightMargin

源码的层级很多,但一步步分析下来,耐着性子看,其实也不复杂, 而且googleAndroid工程师的代码确实有参考的地方,设计模式也随处可见, 比如利用工厂模式创建mOrientationHelper.

总之, ItemDecorationgetItemOffset可理解为给子View再加一层margin.

实践

源码分析完了, 我们再来实践下吧.

LinearLayoutManager

我们先考虑LinearLayoutManager,orientationvertical时的情形: 假设我们现在的需求是RecyclerViewpaddingLeft,paddingRight12dp,paddingTop,paddingBottom20dp, 每个item间的间距是4dp.在不了解ItemDecoration前我们会怎么处理呢.

一般情况下, 我们有两个方案:

  1. 直接设置RecyclerViewpadding,然后设置clipToPadding属性, 最后给itemView设置marginTop或者marginBottom,并在onBindViewHolder时,处理position==0 或者 position==itemCount的特殊item

  2. onBindViewHolder时, 当position==0设置marginTop=20dp , 当position==itemCount设置marginBottom=20dp,其他position设置marginTop=4dp或者marginBottom=4dp

上面两个方案都是可行的, 但也会让ViewHolder变得臃肿, 我们可以利用ItemDecoration来处理,实际上就是把上述逻辑放到ItemDecorationgetItemOffset方法中,然后通过RecyclerViewaddItemDerocation方法添加我们自定义的ItemDecoration就ok了.

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val adapter = parent.adapter
        val itemCount = adapter?.itemCount ?: 0
        val childAdapterPosition = parent.getChildAdapterPosition(view)
        if (itemCount > 0) {
            when (childAdapterPosition) {
                0 -> // 第一个要设置marginTop
                    outRect.set(
                        12,
                        20,
                        12,
                        4
                    )
                itemCount - 1 -> // 最后一行要设置marginBottom
                    outRect.set(
                        12,
                        0,
                        12,
                        20
                    )
                else -> outRect.set(
                    12,
                    0,
                    12,
                    4
                )
            }
        }
    }

GridLayoutManager

我们再来考虑GridLayoutManager,orientationvertical,span为4时的情形: 假设我们现在的需求是RecyclerViewpaddingLeft,paddingRight12dp,paddingTop,paddingBottom20dp, 每个item间的横向间距horizontalSpace10dp,纵向间距是4dp.

同样我们也可以利用ItemDecoration来处理, 先说下思路:

  1. 第一行的item设置marginTop为20dp
  2. 最后一行的item设置marginBottom为20dp
  3. 其他item设置marginTop,或者marginBottom为4dp
  4. 动态计算每一个item的marginLeftmarginRight,保证marginLeft+width+marginRight相等, 以达到均分的目的.

上面最重要的是第四步骤, 我们再来分析如何计算

因为paddingLeftpaddingRight是12dp, 每个item间横向距离是10dp, 所以每一个item的marginLeft+marginRight 应该是

mEachSpace = ( paddingLeft+paddingRight + (spanCount -1) * horizontalSpace)/4 = 13.5

每行第一列item的 marginLeft = paddingLeft = 12 每行最后一列item的 marginRight= paddingRight = 12, 那么最后一列item的marginLeft = mEachSpace -paddingRight = 1.5. 实际上每个item的marginLeftmarginRight都是等差数列, 以marginLeft为例子: 已知A4 = 1.5,A1 = 12, 根据等差数列公式 An = (n - 1) * d + A1 可以算出

d =( A4 - A1) / (4 - 1) = -3.5, 有了 d 我们又可以算出A2= 8.5 ,A3= 5.

那么对应的marginRight Bn也可以根据mEachSpace-An算出:

B1 = 13.5 - 12 = 1.5
B2 = 13.5 - 8.5 = 5
B3 = 13.5 - 5 =  8.5
B4 = 13.5 - 1.5 = 12

经过上述分析, 我们可以在getItemOffset里面这样处理,伪代码如下:

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        val spanCount = 4
        val adapter = parent.adapter
        val itemCount = adapter?.itemCount ?: 0
        val childAdapterPosition = parent.getChildAdapterPosition(view)
        if (itemCount > 0 && mOrientationDecorationHelper.needOffset(outRect, view, parent)) {
            val mEachSpace = (12 * 2 + (spanCount - 1) * 10) / spanCount
            // 一种是vertical
            val diff = ((mEachSpace - 12) - 12) / (spanCount - 1)  // d = (An-A1)/(n-1)
            val column = 根据position计算列数(childAdapterPosition)  // 列数 从0开始计数
            val left = (column + 1 - 1) * diff + 12 // left = (column-1)*diff+A1
            val right = mEachSpace - left
            val currentGroup = 根据position计算行数(childAdapterPosition)
            val totalGroup = 计算总行数()
            // 如果就只有一行
            when (currentGroup) {
                0 -> // 首行要设置marginTop
                    outRect.set(
                        left,
                        20,
                        right,
                        0
                    )
                totalGroup -> // 最后一行要设置marginBottom
                    outRect.set(
                        left,
                        0,
                        right,
                        20
                    )
                else ->
                    outRect.set(
                        left,
                        0,
                        right,
                        4
                    )
            }
        }
    }

结语

上面我们通过LinearLayoutManager,GridLayoutManager orientationVERTICAL时候的具体例子, 当orientationHORIZONTAL时, 处理逻辑也差不多, 只需要把left,right赋值与topbottom对调就行. 很像mOrientationHelper中的逻辑,当然还有一些细节是需要处理的.比如

  1. LayoutManagerreverseLayouttrue时候的情形.
  2. 只有一行或者只有一列的情形
  3. GridLayoutManager设置了SpanSizeLookup的情形

这些细节问题大家可以自行处理, 当然对于这些情形的处理,我也为大家写了一个工具类,放个github地址,求start!哈哈!!