MeasureSpec是父容器向子 View 传递尺寸约束的唯一载体:一个 32 位 int,高 2 位是测量模式,低 30 位是测量尺寸。
1. 为什么需要 MeasureSpec
Android 的 View 是"父管子"的树形结构:子 View 知道自己内容需要多大,父容器知道这块地盘还剩多大。两者信息不对称,就需要一份协商协议——只传一个 int size 无法区分"硬性要求"和"上限",所以有了 MeasureSpec:尺寸 + 意图。
整条链路分四步:
① 父容器(比如 LinearLayout)
│ 用自己的 MeasureSpec + 子的 LayoutParams,算出子该遵守的 spec
▼
② 子 View.measure(childWidthSpec, childHeightSpec)
│ measure() 内部回调 onMeasure(),子在这里算出「我要多宽多高」
▼
③ 子 setMeasuredDimension(w, h)
│ 把算出的结果分别写进 mMeasuredWidth 和 mMeasuredHeight 两个字段
▼
④ 父容器读回这两个值,在 onLayout() 里摆放子的位置(测量阶段至此结束)
一句话概括:父出规则 → 子做计算 → 父收结果。规则来自「父的 MeasureSpec + 子的 LayoutParams」,计算发生在 onMeasure(),结果被父容器用在 onLayout() 里。
2. 二进制结构:一个 int 装两样东西
MeasureSpec 本质上是对一个 int 的封装(Java 里是 View 的静态内部类,Kotlin 里可以理解为一个 value class)。
31 30 | 29 .................................. 0
┌──────┬───────────────────────────────────────┐
│ Mode │ Size │
│ 2 bit│ 30 bit │
└──────┴───────────────────────────────────────┘
↑ 高 2 位存模式 ↑ 低 30 位存尺寸
核心常量:
public static class MeasureSpec {
private static final int MODE_SHIFT = 30;
private static final int MODE_MASK = 0x3 << MODE_SHIFT; // 0xC0000000
public static final int UNSPECIFIED = 0 << MODE_SHIFT; // 0x00000000
public static final int EXACTLY = 1 << MODE_SHIFT; // 0x40000000
public static final int AT_MOST = 2 << MODE_SHIFT; // 0x80000000
}
打包与解包:
// 打包:把 size 截断到 30 位,和 mode 按位或
public static int makeMeasureSpec(int size, int mode) {
// 保证 size 不会污染高 2 位的 mode
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
// 解包
public static int getMode(int measureSpec) {
return (measureSpec & MODE_MASK);
}
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK); // 即 0x3FFFFFFF,最大约 1G
}
手动构造 spec 的常见写法:
int spec = View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY);
view.measure(spec, spec);
3. 三种测量模式
| 模式 | 常量 | 父容器的含义 | 子 View 应该怎么做 |
|---|---|---|---|
| EXACTLY | 1 << 30 | 你的尺寸已经定了,就是 size | 直接使用 specSize,不要再争辩 |
| AT_MOST | 2 << 30 | 你最大不能超过 size | 取「内容需要的大小」与 specSize 的较小值 |
| UNSPECIFIED | 0 | 我不管你,你自己随意 | 完全按内容需要返回(可滚动容器常用) |
对应到 XML 属性,直觉上:
| LayoutParams | 父为 EXACTLY | 父为 AT_MOST | 父为 UNSPECIFIED |
|---|---|---|---|
match_parent | EXACTLY(父可用空间) | AT_MOST(父可用空间) | UNSPECIFIED(0 或父可用空间) |
wrap_content | AT_MOST(父可用空间) | AT_MOST(父可用空间) | UNSPECIFIED(0 或父可用空间) |
100dp | EXACTLY(100dp) | EXACTLY(100dp) | EXACTLY(100dp) |
可以看出:具体数值永远变成 EXACTLY,这是最稳定的一条规则。
典型的 UNSPECIFIED 场景:
ScrollView测量唯一子 View 的高度(只能滚一屏,所以高度不设上限)。ListView/RecyclerView测量 item 的高度。- 手动调用
view.measure(0, 0)或makeMeasureSpec(0, UNSPECIFIED)做预测量。 PopupWindow在WRAP_CONTENT下测量内容。
4. 核心 API 速查
// ---- 打包 / 解包 ----
View.MeasureSpec.makeMeasureSpec(int size, int mode) // 构造一个 measuicSpec
View.MeasureSpec.getMode(int measureSpec) // 取模式
View.MeasureSpec.getSize(int measureSpec) // 取尺寸
View.MeasureSpec.toString(int measureSpec) // 调试打印,如 "EXACTLY 1080"
// ---- 父容器用它给子 View 生成 spec ----
ViewGroup.getChildMeasureSpec(int spec, int padding, int childDimension)
// ---- 子 View 用它裁剪自己的测量结果 ----
View.getDefaultSize(int size, int measureSpec)
View.resolveSize(int size, int measureSpec) // 返回纯尺寸
View.resolveSizeAndState(int size, int ms, int childMeasuredState) // 带状态位
// ---- 子 View 上报结果 ----
View.setMeasuredDimension(int measuredWidth, int measuredHeight)
View.getMeasuredWidth() / getMeasuredHeight()
View.getMeasuredState()
5. MeasureSpec 从哪来:测量规则的向下传递
5.1 树顶:DecorView / ViewRootImpl
测量从根节点开始。ViewRootImpl.performMeasure() 会拿到窗口可用尺寸,然后调用:
private static int getRootMeasureSpec(int windowSize, int measurement) {
int measureSpec;
switch (measurement) {
case ViewGroup.LayoutParams.MATCH_PARENT:
// 撑满窗口 → 硬性要求
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
// 最多撑满窗口 → 上限
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
// 具体数值 → 硬性要求
measureSpec = MeasureSpec.makeMeasureSpec(measurement, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
这就是"根 MeasureSpec 的生成规则",也是整个映射表的第一行原型:MATCH_PARENT → EXACTLY、WRAP_CONTENT → AT_MOST、固定值 → EXACTLY。
5.2 中间层:ViewGroup.getChildMeasureSpec
每一个 ViewGroup 在测量自己的孩子时,都要把「自己的 spec」和「孩子的 LayoutParams」合成「孩子的 spec」。这段逻辑被抽取成了公共静态方法:
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);
// 可用空间 = 父给的尺寸 - 已占用的 padding / margin
int size = Math.max(0, specSize - padding);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
// 父容器自己被强制固定大小
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
// 子 View 要具体数值,满足它
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// 子 View 想和父一样大 → 正好和父一样大
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// 子 View 想自己决定,但绝不能超过父
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// 父容器自己也有上限
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// 想撑满,但父自己都没定下来 → 至少不能超过父的上限
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// 父容器让子 View 随便:模式一定还是 UNSPECIFIED,
// size 那一格填 0 还是父的可用尺寸,要看框架内部的兼容开关
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
padding 参数传入的一般是:父的 padding + 子在父中的 margin + 已消耗的空间(如 LinearLayout 已排在前面的兄弟高度)。
6. 父 → 子 映射矩阵
把 5.2 的 switch 整理成表,就是测量体系的真值表,建议背下来:
| 父 Mode ↓ \ 子 LayoutParams → | 具体数值 100dp | match_parent | wrap_content |
|---|---|---|---|
| EXACTLY | EXACTLY(100dp) | EXACTLY(父可用) | AT_MOST(父可用) |
| AT_MOST | EXACTLY(100dp) | AT_MOST(父可用) | AT_MOST(父可用) |
| UNSPECIFIED | EXACTLY(100dp) | UNSPECIFIED(0 或父可用) | UNSPECIFIED(0 或父可用) |
三条记忆口诀:
- 具体数值永远 EXACTLY(三个格子一模一样)。
wrap_content在父有边界时永远是 AT_MOST(EXACTLY 和 AT_MOST 两行一样)。- 父 UNSPECIFIED 时,
match_parent会退化成 UNSPECIFIED(约束链条断了);size 那格填 0 还是父的可用尺寸,由框架内部的开关决定。
7. measure() / onMeasure() / setMeasuredDimension() 调用链
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
// 优化:spec 没变、也不需要强制布局时,直接复用上次结果
boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
|| heightMeasureSpec != mOldHeightMeasureSpec;
final boolean isSpecExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
&& MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
final boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
&& getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);
final boolean needsLayout = specChanged
&& (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);
if (forceLayout || needsLayout) {
mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;
// ... 这里会调用 onMeasure 的协商逻辑
int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
if (cacheIndex < 0 || sIgnoreMeasureCache) {
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
} else {
// 命中测量缓存(本质是 LongSparseLongArray,key 是两个 spec 拼成的 long)
long value = mMeasureCache.valueAt(cacheIndex);
setMeasuredDimensionRaw((int) (value >> 32), (int) value);
mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
}
mOldWidthMeasureSpec = widthMeasureSpec;
mOldHeightMeasureSpec = heightMeasureSpec;
// 把两个 spec 拼成一个 long 作为缓存 key
mMeasureCache.put((((long) widthMeasureSpec << 32) | (heightMeasureSpec & 0xffffffffL)), ...);
}
三个必须记住的结论:
measure()是final的,不能重写;只能重写onMeasure()。measure()有缓存:key 是两个 spec 拼成的long。如果 spec 没变且没有PFLAG_FORCE_LAYOUT,onMeasure()根本不会被调用。 → 这解释了一个经典困惑: "我手动调用两次view.measure(),第二次没反应" 。因为 spec 相同,走了缓存。要么传不同的 spec,要么先requestLayout()。- 必须调用
setMeasuredDimension(),否则抛IllegalStateException: View with id ... onMeasure() did not set the measured dimension by calling setMeasuredDimension()。
setMeasuredDimension 内部会走 setMeasuredDimensionRaw:
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
mMeasuredWidth = measuredWidth;
mMeasuredHeight = measuredHeight;
mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}
注意 mMeasuredWidth 里除了尺寸还打包了 MEASURED_STATE_* 状态位(由 resolveSizeAndState 产生),所以:
// 取纯尺寸用 getMeasuredWidth()
// 取带状态的结果用 getMeasuredWidthAndState()
8. getDefaultSize 与 resolveSize:尺寸裁剪的核心
8.1 getDefaultSize —— 最"偷懒"但也最常用的取法
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size; // 用调用方给的默认值
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize; // 直接用父给的值
break;
}
return result;
}
View.onMeasure() 的默认实现:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(
getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
这就是那个著名坑的来源:
AT_MOST时getDefaultSize直接返回specSize,也就是"父给你多少你就占多少"。 所以一个不重写onMeasure的自定义 View,wrap_content的表现和match_parent完全相同。 正确的做法是:min(specSize, 内容需要的大小)。
8.2 resolveSize / resolveSizeAndState —— 推荐用法
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
final int specMode = MeasureSpec.getMode(measureSpec);
final int specSize = MeasureSpec.getSize(measureSpec);
final int result;
switch (specMode) {
case MeasureSpec.AT_MOST:
if (specSize < size) {
result = specSize | MEASURED_STATE_TOO_SMALL; // 打上"被挤小了"的标记
} else {
result = size;
}
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
case MeasureSpec.UNSPECIFIED:
default:
result = size;
}
return result | (childMeasuredState & MEASURED_STATE_MASK);
}
resolveSize(size, spec)=resolveSizeAndState(size, spec, 0) & MEASURED_SIZE_MASK。MEASURED_STATE_TOO_SMALL只影响高 8 位的状态区,不影响getMeasuredWidth()返回值,但父容器(如LinearLayout、TextView的 ellipsize 判断)会读它。childMeasuredState用于把子 View 的状态沿着树向上传播,这样View.mergeMeasuredState类逻辑可以判断整条链路上是否有被压缩的节点。
9. 实战:自定义 View 的三种典型写法
9.1 标准模板(推荐)
class SquareTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = measuredWidth
val height = measuredHeight
// 复用 super 的结果,按需修正
val size = min(width, height)
setMeasuredDimension(
resolveSize(size, widthMeasureSpec),
resolveSize(size, heightMeasureSpec)
)
}
}
9.2 严格按 MeasureSpec 三模式处理(最规范)
9.1 是"先让 super 测一遍,再修正结果",好处是省事;但它默认接受了 super 的判断,遇到 wrap_content 这类需要"按内容算"的场景就不够用。要完全掌控尺寸,就得自己把三个模式都写清楚。
下面把 9.1 的 SquareTextView 补成完整可用的类:内容尺寸由文字实际宽高决定(这样才能覆盖三种模式),最后再把渲染区域收敛成正方形。
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import kotlin.math.max
import kotlin.math.min
/**
* 方形文字标签:内容尺寸由文字决定,最终渲染区域收敛成正方形。
* 因为尺寸依赖内容,所以必须完整处理 EXACTLY / AT_MOST / UNSPECIFIED 三种模式。
*/
class SquareTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.DKGRAY
textSize = sp(14f)
textAlign = Paint.Align.CENTER
}
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#11000000")
}
private val bgRect = RectF()
/** 文字内容。改内容必须重新测量,所以要调 requestLayout() */
var text: CharSequence = ""
set(value) {
if (field != value) {
field = value
requestLayout()
invalidate()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val wMode = MeasureSpec.getMode(widthMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
val hMode = MeasureSpec.getMode(heightMeasureSpec)
val hSize = MeasureSpec.getSize(heightMeasureSpec)
// ① 按默认字号先测出文字宽高
var size = sp(14f)
val minSize = sp(8f)
textPaint.textSize = size
var textW = if (text.isEmpty()) 0f else textPaint.measureText(text.toString())
var textH = if (text.isEmpty()) 0f else textPaint.fontMetrics.let { it.descent - it.ascent }
// ② 若文字宽度大于高度,等比缩小字号让其能放进正方形
if (text.isNotEmpty() && textW > textH && size > minSize) {
size = max(minSize, size * (textH / textW))
textPaint.textSize = size
textW = textPaint.measureText(text.toString())
textH = textPaint.fontMetrics.let { it.descent - it.ascent }
}
// ③ 计算加上 padding / suggestedMinimum 后的正方形边长
val contentSide = max(textW.toInt(), textH.toInt())
val sideWithPaddingW = contentSide + paddingLeft + paddingRight
val sideWithPaddingH = contentSide + paddingTop + paddingBottom
val desired = max(
max(sideWithPaddingW, suggestedMinimumWidth),
max(sideWithPaddingH, suggestedMinimumHeight)
)
// ④ 根据父约束收尾:EXACTLY 父说了算,AT_MOST 不能超,UNSPECIFIED 按内容
val allowW = when (wMode) {
MeasureSpec.EXACTLY -> wSize
MeasureSpec.AT_MOST -> min(desired, wSize)
else -> desired
}
val allowH = when (hMode) {
MeasureSpec.EXACTLY -> hSize
MeasureSpec.AT_MOST -> min(desired, hSize)
else -> desired
}
// ⑤ 收敛成正方形:取两边的较小值
val side = min(allowW, allowH)
setMeasuredDimension(
resolveSize(side, widthMeasureSpec),
resolveSize(side, heightMeasureSpec)
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 背景撑满整个测量区域(这里才用 getWidth/measuredWidth,且已在 layout 之后)
bgRect.set(0f, 0f, measuredWidth.toFloat(), measuredHeight.toFloat())
canvas.drawRoundRect(bgRect, 8f, 8f, bgPaint)
// 水平居中靠 textAlign = CENTER,垂直居中靠基线 = 中心 − (ascent + descent) / 2
val fm = textPaint.fontMetrics
val baseline = measuredHeight / 2f - (fm.ascent + fm.descent) / 2f
canvas.drawText(text, 0, text.length, measuredWidth / 2f, baseline, textPaint)
}
private fun sp(value: Float): Float =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, value, resources.displayMetrics
)
}
**为什么最后又要 `resolveSize` 一次?** `side` 取的是"两个方向里更小的那个",直接用它会丢掉 `EXACTLY` 的约束——父容器明确要求宽 1000、高 300 时,硬凑正方形反而违反了父的要求。再各自 `resolveSize` 一遍,`EXACTLY` 方向会还原成父给的尺寸,只有 `AT_MOST` / `UNSPECIFIED` 方向才收敛到 `side`。这与 9.1 末尾的处理方式一致。
用法:
<com.example.SquareTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="12dp" />
squareTextView.text = "待审批"
**四个必须做到的点**
| 要点 | 说明 |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------- |
| ① `desired` 里要算全 | padding + 内容尺寸,还要和 `suggestedMinimumWidth/Height`(即 `minWidth` / `minHeight`)取 `max`,否则 `android:minWidth` 会失效 |
| ② `AT_MOST` 必须 `min()` | 这是"上限"的语义所在。漏了这一步,`wrap_content` 就会变成吃满父容器 |
| ③ `UNSPECIFIED` 忽略 `size` | 直接用内容尺寸。这里的 `size` 可能是 0,也可能是父容器给的提示值,都不该拿去当尺寸 |
| ④ 先 `setMeasuredDimension` 再读 | 在它之前读 `measuredWidth` 拿到的是上一轮的值 |
如果不想手写 `when`,`finalW` / `finalH` 两段可以等价替换成:
val finalW = resolveSize(desiredW, widthMeasureSpec) val finalH = resolveSize(desiredH, heightMeasureSpec)
`resolveSize` 内部就是同样的三分支(见 8.2 节),只是额外带了 `MEASURED_STATE_TOO_SMALL` 状态位。**手写的价值在于:每个分支的意图都写在注释里,出问题时一眼能看出哪条走错了。**
### 9.3 固定宽高比(如 16:9 的 Banner)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val width = MeasureSpec.getSize(widthMeasureSpec) // 高度不参考父的 heightSpec,自己按比例算出来,然后强制 EXACTLY val height = (width * 9f / 16f).toInt() setMeasuredDimension( width, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) ) }
> 注意这里直接给 `setMeasuredDimension` 的第二个参数是 **spec 而不是尺寸**——这是 `setMeasuredDimension` 的一个陷阱:它接收的是**已经解析后的尺寸值**,但如果你传 spec,恰好也能跑(数值是 spec 的 int)。所以上面必须**显式构造 spec 拿到 `getSize`**,或者干脆传 `height`。规范写法:
val height = (width * 9f / 16f).toInt() val heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) setMeasuredDimension(width, MeasureSpec.getSize(heightSpec))
### 9.4 强制测量:想知道"内容自然尺寸"
// 让 View 在无约束下测一次,得到它的"理想尺寸" val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) view.measure(spec, spec) val naturalWidth = view.measuredWidth
这个模式在「文字气泡自适应宽度」「给自定义容器算 item 大小时」非常常用。
* * *
## 10. 实战:自定义 ViewGroup
自定义 ViewGroup 的 `onMeasure` 标准骨架只有四步:
import android.content.Context import android.util.AttributeSet import android.view.View import android.view.ViewGroup import com.jzh.mvvm.R
/**
-
流式布局(FlowLayout)
-
支持特性:
-
- 自动换行
-
- 水平间距 itemSpacing
-
- 垂直行间距 lineSpacing
-
- 子 View margin
-
- padding
-
- wrap_content / match_parent */ class FlowLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr) {
/** 同一行内子 View 之间的水平间距 */ var itemSpacing: Int = 0 private set
/** 行与行之间的垂直间距 */ var lineSpacing: Int = 0 private set
/** 缓存测量阶段得到的换行信息,供 onLayout 复用 */ private val lines = mutableListOf()
private data class Line( val children: MutableList = mutableListOf(), var width: Int = 0, var height: Int = 0 )
init { context.theme.obtainStyledAttributes( attrs, R.styleable.FlowLayout, defStyleAttr, 0 ).apply { try { itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_itemSpacing, 0) lineSpacing = getDimensionPixelSize(R.styleable.FlowLayout_lineSpacing, 0) } finally { recycle() } } }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { lines.clear()
val widthMode = MeasureSpec.getMode(widthMeasureSpec) val widthSize = MeasureSpec.getSize(widthMeasureSpec) val availableWidth = if (widthMode == MeasureSpec.UNSPECIFIED) { Int.MAX_VALUE } else { widthSize - paddingLeft - paddingRight } var lineWidth = 0 var lineHeight = 0 var totalWidth = 0 var totalHeight = 0 var currentLine = Line() for (i in 0 until childCount) { val child = getChildAt(i) if (child.visibility == GONE) continue measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0) val lp = child.layoutParams as MarginLayoutParams val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin if (currentLine.children.isEmpty()) { currentLine.children.add(child) currentLine.width = childWidth currentLine.height = childHeight lineWidth = childWidth lineHeight = childHeight } else { val expectedWidth = lineWidth + itemSpacing + childWidth if (expectedWidth > availableWidth) { // 当前行放不下,先结束当前行 lines.add(currentLine) totalWidth = maxOf(totalWidth, lineWidth) totalHeight += lineHeight // 开启新行 currentLine = Line(mutableListOf(child), childWidth, childHeight) lineWidth = childWidth lineHeight = childHeight } else { // 可以继续放在当前行 currentLine.children.add(child) currentLine.width = expectedWidth currentLine.height = maxOf(currentLine.height, childHeight) lineWidth = expectedWidth lineHeight = maxOf(lineHeight, childHeight) } } } if (currentLine.children.isNotEmpty()) { lines.add(currentLine) totalWidth = maxOf(totalWidth, lineWidth) totalHeight += lineHeight } val verticalExtra = if (lines.size > 1) (lines.size - 1) * lineSpacing else 0 val desiredWidth = totalWidth + paddingLeft + paddingRight val desiredHeight = totalHeight + verticalExtra + paddingTop + paddingBottom val measuredWidth = when (widthMode) { MeasureSpec.EXACTLY -> widthSize MeasureSpec.UNSPECIFIED -> desiredWidth else -> minOf(desiredWidth, widthSize) } val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec) setMeasuredDimension(measuredWidth, measuredHeight)}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { var top = paddingTop for (line in lines) { var left = paddingLeft for (child in line.children) { val lp = child.layoutParams as MarginLayoutParams
left += lp.leftMargin // 在当前行内垂直居中 val verticalSpace = line.height - (child.measuredHeight + lp.topMargin + lp.bottomMargin) val childTop = top + lp.topMargin + verticalSpace / 2 child.layout( left, childTop, left + child.measuredWidth, childTop + child.measuredHeight ) left += child.measuredWidth + lp.rightMargin + itemSpacing } top += line.height + lineSpacing }}
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams { return MarginLayoutParams(context, attrs) }
override fun generateDefaultLayoutParams(): LayoutParams { return MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) }
override fun generateLayoutParams(p: LayoutParams): LayoutParams { return MarginLayoutParams(p) }
override fun checkLayoutParams(p: LayoutParams): Boolean { return p is MarginLayoutParams } }
要点:
- **永远用 `getChildMeasureSpec(...)`,不要自己拼 `makeMeasureSpec`**,否则 `match_parent` / `wrap_content` 会失效。
- `padding` 参数要算全:父自己的 padding + 子的 margin + 已占用空间。
- 测量完必须在 `onLayout` 里用**同样的算法**摆位置,别在 `onLayout` 里重新算(会把测量缓存和布局结果搞得不一致)。
- 想支持 `wrap_content` 宽度,必须正确处理 `availableWidth = Int.MAX_VALUE` 这种情况,否则会拿到 `availableWidth = 0`。