横向网格RecyclerView分割线的坑

问题现象

横向网格指的就是方向为HORIZONTAL的GridLayoutManager。

坑指的就是下图中所示行间间隔内不是红色的部分出现的原因。

图中红色部分为绘制好的分割线,很明显只给第二行的top设置了分割线,第一行的bottom是0,但是为什么会有一条透明的高度等于第二行top的间隔出现呢?

经过调试,得出以下测试结果:

  • 无论top设置多少,这个透明间距的高度就是多少。

  • 将第二行top改为第二行bottom,第一行下方还是有一个等高的透明间距。

  • 将第一行的top活bottom设置一定的高度,第二行的bottom也会出现一个等高的透明间距。

猜测

GridLayoutManager限制了一致的子View高度

分析源码

RecyclerView用来处理布局的类是LayoutManager,处理ItemView布局的方法也很好找到。

/**
 * Lay out all relevant child views from the given adapter.
 * ***动画注释省略***
 * @param recycler Recycler to use for fetching potentially cached views for a
 *                 position
 * @param state    Transient state of RecyclerView
 */
public void onLayoutChildren(Recycler recycler, State state) {
    Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, State state) ");
}

通过不同的LayoutManager子类处理不同的布局方式,这次我们要分析的是GridLayoutManager,它继承至LinearLayoutManager,发现复写的onLayoutChildren方法中主要的布局逻辑在LinearLayoutManager中。

接近200行的布局逻辑,非常多的状态逻辑判断,肯定不是我们关注的重点,我们可以先根据注释大致了解一下onLayoutChildren方法处理的大致逻辑。

  1. 通过检查子View和其他变量,找到一个锚点坐标和一个锚点View的位置。

  2. 朝向start方向,从底部开始堆叠。

  3. 朝向end方向,从顶部开始堆叠。

  4. 滚动来满足从下往上堆叠的要求。

  5. 创建布局状态。

主要意思就是按照方向进行布局堆叠,提供可滑动的布局,以满足堆叠起来的布局可见的需求。

很明显,详细的布局逻辑封装在fill方法中。

通过while循环进行子View的measure,layout操作,主要逻辑在layoutChunk方法中。

layoutChunk方法中可以看到我们熟悉的addView()、measure()、layout()等布局方法。但是可以看到GridLayoutManager完全复写了layoutChunk方法,毕竟它的网格布局与线性布局的布局算法必定完全不同。

// ...
// 以本问题的示例情况来分析
for (int i = 0; i < count; i++) {
    // 取出对应位置的View
    View view = mSet[i];
    // 添加到RecyclerView中
    if (layoutState.mScrapList == null) {
        if (layingOutInPrimaryDirection) {
            addView(view);
        } else {
            addView(view, 0);
        }
    } else {
        if (layingOutInPrimaryDirection) {
            addDisappearingView(view);
        } else {
            addDisappearingView(view, 0);
        }
    }
    // 将ItemDecoration设置的边距赋值给view.layoutParams.mDecorInsets变量中记录
    calculateItemDecorationsForChild(view, mDecorInsets);
    // 初次计算子View宽高
    measureChild(view, otherDirSpecMode, false);
    // 获取itemView的宽
    final int size = mOrientationHelper.getDecoratedMeasurement(view);
    if (size > maxSize) {
        // 计算最大的宽
        maxSize = size;
    }
    // 计算itemView的高
    final LayoutParams lp = (LayoutParams) view.getLayoutParams();
    final float otherSize = 1f * mOrientationHelper.getDecoratedMeasurementInOther(view)
            / lp.mSpanSize;
    if (otherSize > maxSizeInOther) {
        // 计算最大的高
        maxSizeInOther = otherSize;
    }
}
// 示例情况下为true
if (flexibleInOtherDir) {
    // re-distribute columns
    // 根据itemView最大高度重新计算一遍mCachedBorders
    guessMeasurement(maxSizeInOther, currentOtherDirSize);
    // now we should re-measure any item that was match parent.
    maxSize = 0;
    // 循环所有的子View,重新计算宽高
    for (int i = 0; i < count; i++) {
        View view = mSet[i];
        measureChild(view, View.MeasureSpec.EXACTLY, true);
        final int size = mOrientationHelper.getDecoratedMeasurement(view);
        if (size > maxSize) {
            maxSize = size;
        }
    }
}

// ...

大致的计算逻辑在代码中注释了,主要关注这三个点:

  • 计算出itemView的最大高度maxSizeInOther

  • 根据最大高度maxSizeInOther重新计算mCachedBorders,guessMeasurement()

  • 最后重新循环计算子View宽高,measureChild()

maxSizeInOther

根据代码可以知道这个最大高度是包含ItemDecoration中设置的边距的。最终maxSizeInOther=view.getMeasuredHeight()+paddingTop+paddingBottom+marginTop+marginBottom+insetTop+insetBottom(inset即ItemDecoration的边距)

按照示例假设:maxSizeInOther = 100(px)。

guessMeasurement()

/**
 * This is called after laying out a row (if vertical) or a column (if horizontal) when the
 * RecyclerView does not have exact measurement specs.
 * <p>
 * Here we try to assign a best guess width or height and re-do the layout to update other
 * views that wanted to MATCH_PARENT in the non-scroll orientation.
 *
 * @param maxSizeInOther The maximum size per span ratio from the measurement of the children.
 * @param currentOtherDirSize The size before this layout chunk. There is no reason to go below.
 */
private void guessMeasurement(float maxSizeInOther, int currentOtherDirSize) {
    // 最大高度乘以行数,得到RecyclerView内容高度
    final int contentSize = Math.round(maxSizeInOther * mSpanCount);
    // always re-calculate because borders were stretched during the fill
    // 根据内容高度重新计算mCachedBorders
    calculateItemBorders(Math.max(contentSize, currentOtherDirSize));
}

按照示例假设:contentSize = 100*2 = 200

/**
 * @param totalSpace Total available space after padding is removed
 */
private void calculateItemBorders(int totalSpace) {
    mCachedBorders = calculateItemBorders(mCachedBorders, mSpanCount, totalSpace);
}
/**
 * @param cachedBorders The out array
 * @param spanCount number of spans
 * @param totalSpace total available space after padding is removed
 * @return The updated array. Might be the same instance as the provided array if its size
 * has not changed.
 */
static int[] calculateItemBorders(int[] cachedBorders, int spanCount, int totalSpace) {
    if (cachedBorders == null || cachedBorders.length != spanCount + 1
            || cachedBorders[cachedBorders.length - 1] != totalSpace) {
        cachedBorders = new int[spanCount + 1];
    }
    cachedBorders[0] = 0;
    // 每个Span高度=总的内容高度/span数量(2)
    int sizePerSpan = totalSpace / spanCount;
    // 每个Span高度余数
    int sizePerSpanRemainder = totalSpace % spanCount;
    int consumedPixels = 0;
    int additionalSize = 0;
    for (int i = 1; i <= spanCount; i++) {
        int itemSize = sizePerSpan;
        additionalSize += sizePerSpanRemainder;
        if (additionalSize > 0 && (spanCount - additionalSize) < sizePerSpanRemainder) {
            itemSize += 1;
            additionalSize -= spanCount;
        }
        consumedPixels += itemSize;
        cachedBorders[i] = consumedPixels;
    }
    return cachedBorders;
}

按照示例假设:sizePerSpan = 200/2 = 100

sizePerSpanRemainder = 200%2 = 0

cachedBorders[0] = 0;

cachedBorders[1] = 100;

cachedBorders[2] = 200;

measureChild()

/**
 * Measures a child with currently known information. This is not necessarily the child's final
 * measurement. (see fillChunk for details).
 *
 * @param view The child view to be measured
 * @param otherDirParentSpecMode The RV measure spec that should be used in the secondary
 *                               orientation
 * @param alreadyMeasured True if we've already measured this view once
 */
private void measureChild(View view, int otherDirParentSpecMode, boolean alreadyMeasured) {
    final LayoutParams lp = (LayoutParams) view.getLayoutParams();
    final Rect decorInsets = lp.mDecorInsets;
    // 垂直方向上的边距
    final int verticalInsets = decorInsets.top + decorInsets.bottom
            + lp.topMargin + lp.bottomMargin;
    // 水平方向上的边距
    final int horizontalInsets = decorInsets.left + decorInsets.right
            + lp.leftMargin + lp.rightMargin;
    // 有效空间高度
    final int availableSpaceInOther = getSpaceForSpanRange(lp.mSpanIndex, lp.mSpanSize);
    final int wSpec;
    final int hSpec;
    if (mOrientation == VERTICAL) {
        wSpec = getChildMeasureSpec(availableSpaceInOther, otherDirParentSpecMode,
                horizontalInsets, lp.width, false);
        hSpec = getChildMeasureSpec(mOrientationHelper.getTotalSpace(), getHeightMode(),
                verticalInsets, lp.height, true);
    } else {
        // 计算子view的高度
        hSpec = getChildMeasureSpec(availableSpaceInOther, otherDirParentSpecMode,
                verticalInsets, lp.height, false);
        wSpec = getChildMeasureSpec(mOrientationHelper.getTotalSpace(), getWidthMode(),
                horizontalInsets, lp.width, true);
    }
    measureChildWithDecorationsAndMargin(view, wSpec, hSpec, alreadyMeasured);
}

/**
 * @param startSpan 子view的spanIndex,span索引位置,示例为第几行
 * @param spanSize 子view的spanSize,span大小,子view所占用的span个数
 */
int getSpaceForSpanRange(int startSpan, int spanSize) {
    if (mOrientation == VERTICAL && isLayoutRTL()) {
        return mCachedBorders[mSpanCount - startSpan]
                - mCachedBorders[mSpanCount - startSpan - spanSize];
    } else {
        return mCachedBorders[startSpan + spanSize] - mCachedBorders[startSpan];
    }
}

按照示例:availableSpaceInOther = mCachedBorders[0+1]-mCachedBorders[0] = 100

availableSpaceInOther = mCachedBorders[1+1]-mCachedBorders[1] = 100

/**
 * Calculate a MeasureSpec value for measuring a child view in one dimension.
 *
 * @param parentSize     Size of the parent view where the child will be placed
 * @param parentMode     The measurement spec mode of the parent
 * @param padding        Total space currently consumed by other elements of parent
 * @param childDimension Desired size of the child view, or MATCH_PARENT/WRAP_CONTENT.
 *                       Generally obtained from the child view's LayoutParams
 * @param canScroll      true if the parent RecyclerView can scroll in this dimension
 * @return a MeasureSpec value for the child view
 */
public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
        int childDimension, boolean canScroll) {
    // 根据最大可用有效空间大小减去垂直方向上的边距,得到子View最终的高度大小
    int size = Math.max(0, parentSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    if (canScroll) {
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            switch (parentMode) {
                case MeasureSpec.AT_MOST:
                case MeasureSpec.EXACTLY:
                    resultSize = size;
                    resultMode = parentMode;
                    break;
                case MeasureSpec.UNSPECIFIED:
                    resultSize = 0;
                    resultMode = MeasureSpec.UNSPECIFIED;
                    break;
            }
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            resultSize = 0;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
    } else {
        // 按照示例问题,canScroll=false
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // 子view的高度childDimension为MATCH_PARENT
            resultSize = size;// 子view最终的高度为size
            resultMode = parentMode;// 高度模式为View.MeasureSpec.EXACTLY
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            resultSize = size;
            if (parentMode == MeasureSpec.AT_MOST || parentMode == MeasureSpec.EXACTLY) {
                resultMode = MeasureSpec.AT_MOST;
            } else {
                resultMode = MeasureSpec.UNSPECIFIED;
            }
        }
    }
    //noinspection WrongConstant
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

为什么childDimension为MATCH_PARENT

在RecyclerView通过ViewHolder获取子View的过程中,通过默认的LayoutParams构造方法给子View设置了LayoutParams。

RecyclerView

@Override
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
    if (mLayout == null) {
        throw new IllegalStateException("RecyclerView has no LayoutManager" + exceptionLabel());
    }
    // 子View的LayoutParams为空时调用这个方法
    return mLayout.generateDefaultLayoutParams();
}
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
    if (mLayout == null) {
        throw new IllegalStateException("RecyclerView has no LayoutManager" + exceptionLabel());
    }
    // 检测条件达成后调用这个方法
    return mLayout.generateLayoutParams(getContext(), attrs);
}

GridLayoutManager

@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
    if (mOrientation == HORIZONTAL) {
        return new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.MATCH_PARENT);
    } else {
        return new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT);
    }
}
@Override
public RecyclerView.LayoutParams generateLayoutParams(Context c, AttributeSet attrs) {
    return new LayoutParams(c, attrs);
}

可以看到,当方向为横向时,网格布局给子View设置的LayoutParams高度为MATCH_PARENT。

结论

通过最后一步的分析,可以确认子View的高度是通过mCachedBorders这个数组来确定的,并不是通过自身设置的高度来确定的。

mCachedBorders数组的计算正常情况下是一个等差数列,0,100,200这样子,差值就是从每个子View中算出来的最大高度,并且这个高度包含了padding、margin和这个问题的主角ItemDecoration.getItemOffset()方法提供的inset。

当不够这个高度的子view计算时,就会使用这个最大高度来measure,这也就是实例中第一行的高度为什么会和第二行的高度一致的原因。

所以当给GridLayoutManager设置分割线的要点就是保证每个Item的宽度或高度一致,当方向为VERTICAL时保证Item宽度一致,当方向为HORIZONTAL时保证Item高度一致,这里的宽高包含分割线。具体的自定义分割线方法可以看这篇文章

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容