需要实现自适应屏幕宽度的流式布局,具备以下特性:
- 自动换行:每行尽可能多排列子View
- 整体居中:每行内容水平居中显示
在Android开发中,虽然可以通过TextView结合父容器实现基础标签布局,但若直接使用XML静态布局存在明显适配缺陷。
由于不同设备的屏幕尺寸和分辨率存在差异,在开发者设备上能完美单行显示的三个标签,在用户设备上可能因屏幕过窄导致布局错乱。
为保障跨设备的完美呈现,我们需要采用自定义ViewGroup方案:
在自定义容器中覆写onMeasure()和onLayout()方法,实时计算每个子View的尺寸,根据可用宽度进行流式布局,动态确定每个子View的位置坐标和实际占位尺寸,从而实现真正的响应式布局效果。
Android视图系统的布局流程可分为测量(Measure)和布局(Layout)两个核心阶段:
测量阶段
-
自顶向下触发测量链:根View通过measure()方法启动递归测量
-
父子尺寸协商:父View通过MeasureSpec向子View传递尺寸约束(包括最大可用空间和测量模式)
-
子View自测量:在onMeasure()中根据父View的约束条件,结合自身内容特性(如文本长度/图片尺寸)计算期望尺寸
-
容器测量决策:ViewGroup类型控件在onMeasure()中完成:
- 遍历调用所有子View的measure()方法
- 综合子View测量结果与自身布局规则(如LinearLayout的权重机制)
- 最终确定自身和所有子View的实际测量尺寸
布局阶段
-
测量结果传递:根View通过layout()方法触发布局流程
-
坐标分配:父View在onLayout()中根据测量阶段得到的尺寸数据,为每个子View计算具体坐标区域(left/top/right/bottom)
-
子视图定位:通过子View.layout()传递最终显示区域参数,子View将参数存储于自身坐标系中
-
布局级联:ViewGroup在onLayout()中遍历触发所有子View的layout()方法,形成递归布局树
tips:
- 父View拥有最终决策权:子View的测量结果仅作为参考,父View可根据布局规则修改最终尺寸
- 测量分离机制:measure()计算的尺寸数据不会直接影响绘制,直到layout()阶段才确定物理坐标
MeasureSpec结构解析
measure(int widthMeasureSpec, int heightMeasureSpec)
每个MeasureSpec参数(32位整型)包含双重语义:
-
尺寸模式(高2位):通过MeasureSpec.getMode()获取,决定尺寸的约束类型
- EXACTLY:精确尺寸(如match_parent或具体数值)
- AT_MOST:最大限制(如wrap_content)
- UNSPECIFIED:无限制(多用于滚动容器)
-
尺寸值(低30位):通过MeasureSpec.getSize()获取,数值语义由模式决定
子View MeasureSpec生成逻辑
子View的测量参数由以下因素共同决定:
- 父View的约束条件:父View自身的MeasureSpec及已用空间
- 子View布局参数:XML中定义的layout_width/layout_height
- 容器特性:父View的布局规则(如LinearLayout与RelativeLayout的不同处理)
测量参数计算的核心逻辑:
// 以宽度测量为例的典型处理逻辑
when (child.layoutParams.width) {
MATCH_PARENT -> { // 子View期望填满父容器
when (parentMode) {
EXACTLY, AT_MOST -> {
// 继承父容器的剩余空间,采用精确模式
childSize = parentSize - widthUsed
childMode = EXACTLY
}
UNSPECIFIED -> { // 父容器无约束时保持自适应
childSize = 0
childMode = UNSPECIFIED
}
}
}
WRAP_CONTENT -> { // 子视图根据内容自适应
when (parentMode) {
EXACTLY, AT_MOST -> {
// 继承父容器剩余空间,但采用最大限制模式
childSize = parentSize - widthUsed
childMode = AT_MOST
}
UNSPECIFIED -> {
childSize = 0
childMode = UNSPECIFIED
}
}
}
else -> { // 明确指定具体数值
childSize = child.layoutParams.width
childMode = EXACTLY
}
}
MeasureSpec的计算非常公式,Android框架提供标准化方法:ViewGroup.measureChildWithMargins() 封装了以下功能:
-
自动计算子View的margin值
-
处理父容器的padding和已用空间
-
根据子View的LayoutParams生成正确的MeasureSpec
-
自动触发子View的measure()方法
CenterLayout
测量阶段 - onMeasure() 实现解析
测量过程包含三个核心任务:
- 子视图测量:计算每个子View的尺寸
- 布局空间分配:动态计算折行逻辑
- 容器尺寸确定:综合所有子View测量结果确定自身尺寸
关键参数处理
val width = MeasureSpec.getSize(widthMeasureSpec) // 容器可用宽度
var heightUsed = 0 // 累计高度消耗(最终容器高度)
var currentWidth = 0 // 当前行已用宽度
var currentHeight = 0 // 当前行基准高度
子View测量流程
for (child in children) {
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0)
... 处理折行
}
测量后通过child.measuredWidth/Height获取测量的尺寸
代码中通过自定义属性widthUsed/heightUsed 或者测量宽高与margin的合
例如:
val View.heightUsed: Int
get() {
return marginTop + marginBottom + measuredHeight
}
折行决策逻辑
if (currentWidth + child.measuredWidth > width) { // 宽度溢出判断
heightUsed += rowSpacing // 增加行间距
currentHeight = heightUsed // 重置行基准高度
currentWidth = 0 // 重置行宽度计数器
}
坐标记录系统
coordinates.getOrPut(child) { ViewCoordinate() }.set(currentWidth, currentHeight) // 记录子view的坐标
lineWidthUsed[currentHeight] = currentWidth // 记录行总宽度
- ViewCoordinate:存储子View的初始布局坐标
- lineWidthUsed:记录每行最终占用的实际宽度(用于后续居中计算)
容器尺寸确定
setMeasuredDimension(width, resolveSize(heightUsed, heightMeasureSpec))
- 宽度:严格遵循父容器约束尺寸
- 高度:通过
resolveSize()处理AT_MOST/EXACTLY模式差异
布局阶段 - onLayout() 实现解析
核心任务
根据测量阶段记录的坐标数据,实现每行子View的水平居中布局
布局参数计算
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
val w = r - l
children.forEach { view->
val coordinate = coordinates[view] ?: return
val usedWidth = lineWidthUsed.getOrDefault(coordinate.y, null) ?: return
val offset = (w - usedWidth) / 2 + view.marginStart
view.layout(coordinate.x + offset, coordinate.y)
}
}
关键计算步骤
-
获取坐标记录:从
coordinates中读取子View的原始坐标 -
行宽度查询:通过
lineWidthUsed获取该行的总占用宽度 -
居中偏移计算:
(w - usedWidth)/2:计算行内容左右边距+ view.marginStart:叠加子View的起始margin
代码中的layout为自定义函数
fun View.layout(
x: Int,
y: Int,
) {
layout(x, y, x + measuredWidth, y + measuredHeight)
}
最终运行效果如下:
因为文本是随机生成的,每次进入退出后的标签都不一样,且点击后标签文本变长,当上居中与自动折行的逻辑不变