7、自定义ViewGroup的Layout:重写onLayout的步骤、子View位置计算、margin与padding的处理、实战:简易流式布局

好,咱们接着聊。上一章我们把 measure 过程讲透了,这一章轮到 layout 了。

说白了,measure 是「量尺寸」,layout 是「摆位置」。你量好了每个子 View 想要多大,接下来就得告诉它们:你,站这儿;你,站那儿。

我个人觉得,layout 比 measure 要直观一些。因为位置计算嘛,无非就是坐标加减。但坑也不少——尤其是 margin 和 padding 的处理,稍不留神子 View 就「越界」了。

7.1 重写 onLayout 的标准步骤

自定义 ViewGroup 时,onLayout 是必须重写的方法。为什么?因为系统不知道你想怎么排列子 View。LinearLayout 是线性排,FrameLayout 是叠在一起,那你的布局呢?你得自己告诉系统。

标准步骤其实就三步:

  1. 遍历所有子 View:调用 getChildCount() 和 getChildAt()。
  2. 计算每个子 View 的位置:根据你的排列逻辑,算出 left、top、right、bottom。
  3. 调用 child.layout():把算好的坐标传进去。

嗯,这里要注意:child.layout() 和 ViewGroup 的 onLayout() 不是一回事。onLayout 是「你告诉孩子该站哪」,child.layout 是「孩子自己站过去」。前者是父布局的责任,后者是子 View 的行为。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int currentLeft = getPaddingLeft();
    int currentTop = getPaddingTop();

    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) continue;

        int childWidth = child.getMeasuredWidth();
        int childHeight = child.getMeasuredHeight();

        // 计算子 View 的坐标
        int cl = currentLeft;
        int ct = currentTop;
        int cr = cl + childWidth;
        int cb = ct + childHeight;

        child.layout(cl, ct, cr, cb);

        // 更新下一个子 View 的位置
        currentLeft += childWidth;
    }
}

这段代码是最简版本,没有处理 margin,也没有换行。别急,后面会一步步完善。

7.2 子 View 位置计算的核心逻辑

位置计算说白了就是「坐标累加」。你想想看,一个水平排列的布局,第一个子 View 的 left 是 paddingLeft,第二个子 View 的 left 就是 paddingLeft + 第一个子 View 的宽度 + 间距,以此类推。

但这里有个关键点:子 View 的坐标是相对于父 View 的左上角。不是屏幕坐标,也不是父 View 的 content 区域坐标。所以 padding 要手动加上去。

我在项目中遇到过一个问题:自定义了一个标签布局,子 View 总是偏左。查了半天,发现是忘了加 getPaddingLeft()。嗯,这种低级错误,犯过一次就记住了。

核心公式:

  • childLeft = parentPaddingLeft + 已占宽度 + childMarginLeft
  • childTop = parentPaddingTop + 已占高度 + childMarginTop
  • childRight = childLeft + child.getMeasuredWidth()
  • childBottom = childTop + child.getMeasuredHeight()

7.3 margin 与 padding 的处理

padding 是父 View 的内边距,margin 是子 View 的外边距。两者都要在 layout 时考虑进去。

处理 padding 很简单:所有子 View 的起始位置从 getPaddingLeft() 和 getPaddingTop() 开始算。

处理 margin 稍微麻烦一点:你需要通过 LayoutParams 拿到 margin 值。

// 获取子 View 的 LayoutParams,强转为 MarginLayoutParams
ViewGroup.MarginLayoutParams lp = (ViewGroup.MarginLayoutParams) child.getLayoutParams();

int childMarginLeft = lp.leftMargin;
int childMarginTop = lp.topMargin;
int childMarginRight = lp.rightMargin;
int childMarginBottom = lp.bottomMargin;

// 计算时把 margin 加上
int cl = currentLeft + childMarginLeft;
int ct = currentTop + childMarginTop;
int cr = cl + child.getMeasuredWidth();
int cb = ct + child.getMeasuredHeight();

child.layout(cl, ct, cr, cb);

// 更新下一个子 View 的位置时,要加上子 View 的宽度 + 左右 margin
currentLeft += child.getMeasuredWidth() + childMarginLeft + childMarginRight;

注意:如果你的 ViewGroup 支持 margin,必须在构造函数中调用 setClipToPadding(false)?不一定。但如果你希望子 View 的 margin 能「突出」到 padding 区域之外,就需要考虑 clip 的问题。大多数情况下,保持默认即可。

我曾经踩过一个坑:自定义流式布局时,子 View 的 marginBottom 没有加到行高里,导致第二行的子 View 和第一行重叠了。排查了半天,才发现是行高累加时漏了 marginBottom。从那以后,我写 layout 逻辑时都会画个草图,把每个坐标的组成写清楚。

7.4 实战:简易流式布局

流式布局(FlowLayout)是什么?就是子 View 从左到右排列,排不下就换行。类似 TextView 里的文字换行效果。

实现思路:

  1. 遍历所有子 View,累加当前行宽度。
  2. 如果当前行放不下下一个子 View,就换行:重置 currentLeft 为 paddingLeft,currentTop 加上当前行的高度。
  3. 每个子 View 的位置计算要包含 margin。
  4. 记录当前行的最大高度,用于换行时累加。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int paddingLeft = getPaddingLeft();
    int paddingTop = getPaddingTop();
    int paddingRight = getPaddingRight();
    int paddingBottom = getPaddingBottom();

    int parentWidth = getMeasuredWidth() - paddingLeft - paddingRight;

    int currentLeft = paddingLeft;
    int currentTop = paddingTop;
    int lineMaxHeight = 0;

    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) continue;

        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
        int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

        // 判断是否需要换行
        if (currentLeft + childWidth > parentWidth + paddingLeft) {
            // 换行
            currentLeft = paddingLeft;
            currentTop += lineMaxHeight;
            lineMaxHeight = 0;
        }

        // 计算子 View 的实际位置(去掉 margin)
        int cl = currentLeft + lp.leftMargin;
        int ct = currentTop + lp.topMargin;
        int cr = cl + child.getMeasuredWidth();
        int cb = ct + child.getMeasuredHeight();

        child.layout(cl, ct, cr, cb);

        // 更新当前行位置
        currentLeft += childWidth;
        lineMaxHeight = Math.max(lineMaxHeight, childHeight);
    }
}

小技巧:流式布局的 onMeasure 也要同步实现,否则 layout 时拿到的 measuredWidth/measuredHeight 可能是错的。measure 和 layout 永远是成对出现的,别只写一个。

这个简易流式布局已经能处理大部分场景了。当然,生产环境里你可能还需要支持 gravity、最大行数限制、子 View 动画等。但核心逻辑就是上面这些——遍历、计算、换行、layout。

我个人习惯在写自定义 ViewGroup 时,先用硬编码写死几个子 View 测试布局逻辑,没问题了再改成动态添加。这样调试起来快很多。

7.5 本章小结

来,咱们捋一下这章的重点:

  • onLayout 的核心就是「遍历子 View → 算坐标 → 调 layout()」。
  • padding 影响起始位置,margin 影响子 View 之间的间距。
  • 流式布局的本质是「宽度累加,超了换行」。
  • 别忘了处理 GONE 的 View,也别忘强转 MarginLayoutParams。

嗯,layout 这块其实不难,但细节多。你只要动手写一两个自定义布局,这些就全通了。下一章我们会讲 draw 流程,到时候就能看到 View 真正「画」出来是什么样子了。

自定义 ViewGroup Layout 核心流程 onLayout() 被调用 遍历子 View(getChildAt) 是否 GONE? 是 → continue 计算位置:padding + margin + 已占宽度 child.layout(cl, ct, cr, cb) 换行判断 currentLeft + childWidth > parentWidth? 是 → 换行重置 核心:遍历 → 计算坐标(含 padding/margin)→ 换行 → child.layout()
公众号:蓝海资料掘金营,微信 deep3321