问题现象
横向网格指的就是方向为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方法处理的大致逻辑。
通过检查子View和其他变量,找到一个锚点坐标和一个锚点View的位置。
朝向start方向,从底部开始堆叠。
朝向end方向,从顶部开始堆叠。
滚动来满足从下往上堆叠的要求。
创建布局状态。
主要意思就是按照方向进行布局堆叠,提供可滑动的布局,以满足堆叠起来的布局可见的需求。
很明显,详细的布局逻辑封装在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高度一致,这里的宽高包含分割线。具体的自定义分割线方法可以看这篇文章。