GridLayoutManager:商品架的 “宽度分配小秘密”

79 阅读6分钟

用 “商品架工厂” 的设定:GridLayoutManager 是给商店(RecyclerView)做网格货架的厂长,每个 Item 是要摆的 “商品”,宽度分配就是厂长给每个商品 “划地盘”—— 比如 “这个牙膏占 1 格宽,那个促销礼盒占 2 格宽”。今天咱们扒透厂长怎么算 “地盘宽度”,最后再解答 “不放间隔(Margin)会挨在一起吗” 这个关键问题~

一、宽度分配的核心逻辑:先算 “总地盘”,再切 “小格子”,最后给 “商品分格子”

厂长分配宽度的流程就像切蛋糕:先确定 “整个蛋糕有多大”(总可用宽度),再按 “要分几块”(spanCount 列数)切出小格子,最后按商品需求(spanSize 占格数)分格子 —— 每一步都有对应的 “工厂操作手册”(源码方法)。

第一步:算 “整个蛋糕的大小”—— 总可用宽度(updateMeasurements)

商店老板(RecyclerView)说 “我要做垂直货架(VERTICAL)”,厂长第一步要算 “货架真正能摆商品的宽度”—— 得先减去货架边缘的 “安全距离”(padding),因为商品不能贴在货架边框上。

对应源码里的 updateMeasurements() 方法,逻辑超简单:总宽度 - 左 Padding - 右 Padding = 可用宽度(水平布局时是总高度减上下 Padding)。举个具体例子:如果手机屏幕宽度是 360dp,货架左 Padding=16dp、右 Padding=16dp,那可用宽度就是 360 - 16 - 16 = 328dp

private void updateMeasurements() {
    int totalSpace; // 可用宽度(垂直布局时)
    if (getOrientation() == VERTICAL) { 
        // 垂直布局:宽度 = 货架总宽 - 左Padding - 右Padding
        totalSpace = getWidth() - getPaddingRight() - getPaddingLeft();
    } else { 
        // 水平布局:高度 = 货架总高 - 上Padding - 下Padding(今天不聊这个)
        totalSpace = getHeight() - getPaddingBottom() - getPaddingTop();
    }
    calculateItemBorders(totalSpace); // 下一步:把可用宽度切成小格子
}

第二步:切 “小格子”—— 按列数分割宽度(calculateItemBorders)

可用宽度 328dp 算出来了,老板说 “要分 3 列”(spanCount=3),厂长要把 328dp 切成 3 个格子。但问题来了:328÷3=109.333dp,不能有小数点啊!这时候厂长会用 “余数分配法”:把多出来的 1dp 分给第一个格子,让格子宽度变成 110dp、109dp、109dp(总和还是 328dp)。

这个 “切格子” 的逻辑全在 calculateItemBorders() 方法里,最终会生成一个 “边界数组”mCachedBorders—— 比如上面的例子,数组就是 [0, 110, 219, 328],每个数字代表 “格子的右边界”:

  • 第 1 格:从 0 到 110dp(宽 110dp)
  • 第 2 格:从 110 到 219dp(宽 109dp)
  • 第 3 格:从 219 到 328dp(宽 109dp)
static int[] calculateItemBorders(int[] cachedBorders, int spanCount, int totalSpace) {
    // 初始化边界数组:3列需要4个边界(0→1→2→3)
    if (cachedBorders == null || cachedBorders.length != spanCount + 1) {
        cachedBorders = new int[spanCount + 1];
    }
    cachedBorders[0] = 0; // 第一个边界从0开始
    int sizePerSpan = totalSpace / spanCount; // 每格基础宽度:328÷3=109
    int sizePerSpanRemainder = totalSpace % spanCount; // 余数:328%3=1
    int consumedPixels = 0; // 已用宽度
    int additionalSize = 0; // 累计余数

    for (int i = 1; i <= spanCount; i++) {
        int itemSize = sizePerSpan; // 先按基础宽度算
        additionalSize += sizePerSpanRemainder; // 累加余数:1
        
        // 把余数分给前n个格子(n=余数),每个多1dp
        if (additionalSize > 0 && (spanCount - additionalSize) < sizePerSpanRemainder) {
            itemSize += 1; // 第1格变成110dp
            additionalSize -= spanCount; // 余数清零(1-3=-2,后续格子不加)
        }
        
        consumedPixels += itemSize;
        cachedBorders[i] = consumedPixels; // 记录边界:110、219、328
    }
    return cachedBorders; // 返回最终的格子边界数组
}

第三步:给 “商品分格子”—— 按占格数算宽度(getSpaceForSpanRange)

格子切好了,接下来给商品分宽度:普通商品(比如牙膏)占 1 格,宽度就是对应格子的边界差;特殊商品(比如促销礼盒)占 2 格,宽度就是两个格子的边界差。

对应源码里的 getSpaceForSpanRange() 方法,核心逻辑是 商品宽度 = 结束边界 - 起始边界。比如:

  • 牙膏在第 1 格(startSpan=0,spanSize=1):宽度 = 110-0=110dp
  • 礼盒在第 1-2 格(startSpan=0,spanSize=2):宽度 = 219-0=219dp
int getSpaceForSpanRange(int startSpan, int spanSize) {
    // 处理RTL布局(从右往左摆):边界要反向算,比如第1格变成从328→219
    if (mOrientation == VERTICAL && isLayoutRTL()) {
        return mCachedBorders[mSpanCount - startSpan] 
                - mCachedBorders[mSpanCount - startSpan - spanSize];
    } else {
        // 正常LTR布局:直接用结束边界 - 起始边界
        return mCachedBorders[startSpan + spanSize] - mCachedBorders[startSpan];
    }
}

第四步:给 “商品留余地”—— 测量时扣减 Margin/Padding(measureChild)

商品的 “地盘宽度” 算出来了,但商品本身可能需要 “私人空间”(Margin)—— 比如牙膏包装怕挤,要在左右各留 4dp 间隔。这时候厂长会在 “测量商品实际尺寸” 时,把 Margin 从格子宽度里减掉。

对应源码里的 measureChild() 方法,逻辑是:商品实际内容宽度 = 格子宽度 - 左 Margin - 右 Margin - 装饰条宽度(比如分割线) 。比如牙膏格子宽 110dp,左右 Margin 各 4dp,那实际内容宽度就是 110 - 4 - 4 = 102dp(装饰条如果有的话还要再减)。

private void measureChild(View view, int otherDirParentSpecMode, boolean alreadyMeasured) {
    LayoutParams lp = (LayoutParams) view.getLayoutParams();
    Rect decorInsets = lp.mDecorInsets; // 装饰条(比如分割线)的宽度
    
    // 计算总“占用宽度”:Margin + 装饰条
    int horizontalInsets = decorInsets.left + decorInsets.right 
                          + lp.leftMargin + lp.rightMargin;
    
    // 格子宽度(之前算的110dp)
    int availableSpaceInOther = getSpaceForSpanRange(lp.mSpanIndex, lp.mSpanSize);
    
    // 生成“测量要求”:告诉商品“你的内容最多只能占102dp宽”
    int wSpec = getChildMeasureSpec(availableSpaceInOther, otherDirParentSpecMode, 
                                    horizontalInsets, lp.width, false);
    
    // 测量商品(按上面的要求算实际尺寸)
    measureChildWithDecorationsAndMargin(view, wSpec, hSpec, alreadyMeasured);
}

二、关键问题:不设置 Margin,商品会挨在一起吗?

答案是:会!而且会贴得严严实实! 因为 GridLayoutManager 本身不会给商品 “自动加间隔”—— 就像货架上的商品,你不手动留空隙,它们就会紧紧靠在一起。

怎么解决?有 3 种常见办法:

  1. 给 Item 布局加 Margin:在 Item 的 XML 里加 android:layout_margin="4dp",比如:

    <!-- Item布局示例 -->
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="4dp"> <!-- 商品间留4dp间隔 -->
        
        <TextView
            android:id="@+id/tv_name"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:text="牙膏"/>
    </LinearLayout>
    
  2. 用 ItemDecoration 加分割线:通过 recyclerView.addItemDecoration() 加分割线,比如:

    // 简单分割线(4dp间隔)
    recyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
        @Override
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            outRect.set(4, 4, 4, 4); // 上下左右各留4dp间隔
        }
    });
    
  3. 给 Item 加 Padding:如果 Item 背景是纯色,也可以给 Item 加 android:padding="4dp",但要注意 Padding 会让内容变小,和 Margin 的效果不同(Margin 是商品外的间隔,Padding 是商品内的间隔)。

三、宽度分配的 “工厂时序图”—— 关键步骤一目了然

exported_image.png

总结:宽度分配的 “一句话口诀”

先减 Padding 得总宽,按列切格分余数,商品占格算边界差,Margin 扣完是实宽 —— 不设 Margin 就挨一起,加 Margin / 分割线能分开!

如果还是没懂,咱们可以拿具体的数值再走一遍流程~ 比如你想做 2 列货架,屏幕宽度 480dp,左 Padding20dp,右 Padding20dp,那可用宽度是 440dp,每格 220dp,商品占 1 格的话宽 220dp,加 4dp Margin 后实际内容宽 212dp,这样摆出来就有间隔啦!