第 2 章 · 布局与 Modifier 语义:用代码画界面(下篇)

2.9 基础组件与 Spacer:Text/Image/Icon/Button/Surface/Card、spacedBy

一句话 :Compose 的基础 UI 组件(Text、Image、Icon、Button、Surface、Card)都是普通 @Composable 函数,参数即样式;Spacer 是占位空白,Arrangement.spacedBy 是首选的"间隔"方案。

从一个真实的别扭说起

View 里 TextView 设文字、ImageView 设图、Button 设文字+点击,都是"控件 + 属性"。Compose 里这些是"函数 + 参数",且很多"样式"不是独立控件而是 Modifier(TextstyleImagecontentScale)。一开始容易找不到"字体大小在哪设""图片怎么裁切"。

更深的变化是:Compose 把"样式"从"控件自带的 setter"变成了"独立的数据对象" (如 TextStyleShapeColor)。这意味着样式可以脱离具体控件被复用、被主题统一管理------这是 Material3 主题系统能工作的前提(W6 详述)。

它是什么

组件 关键参数 类比 View
Text textstylemaxLinesoverflowfontSize(在 style 里) TextView
Image painter/imageVectorcontentDescriptioncontentScale ImageView
Icon imageVector(如 Icons.Filled.Add)、tint ImageView + 矢量图
Button onClickcontent(slot,放 Text/Row) Button/MaterialButton
Surface colorshapeshadowElevationonClick(可选) Card/FrameLayout+背景
Card Modifiershapecolors(Material3 用 CardDefaults MaterialCardView
Spacer Modifier.height/width(x.dp) 占位的空 View

最小可运行示例

kotlin 复制代码
Column(Modifier.padding(16.dp)) {
    Text(
        "标题",
        style = MaterialTheme.typography.titleLarge,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis
    )
    Spacer(Modifier.height(8.dp)) // 硬编码间隔(不推荐,优先 spacedBy)
    Image(
        painter = painterResource(R.drawable.banner),
        contentDescription = "横幅",
        contentScale = ContentScale.Crop,   // 裁切填满
        modifier = Modifier.fillMaxWidth().height(160.dp)
    )
    Button(onClick = { /* */ }) {
        Icon(Icons.Filled.Favorite, null)
        Spacer(Modifier.width(8.dp))
        Text("点赞")
    }
}

进阶:contentScale 家族

ImagecontentScale 决定"图片如何适配目标框",对应 View 的 scaleType

contentScale 效果 ImageView.scaleType
Fit 等比缩放全显示(可能留白) fitXY(不等比)/fitCenter(等比)
Crop 等比填满、超出裁切 centerCrop
Inside 若图大于框则缩放适应,否则原大 centerInside
FillBounds 拉伸填满(不等比) fitXY
FillWidth 按宽等比、高可能超出 ---
FillHeight 按高等比、宽可能超出 ---
Surface vs Card:什么时候用哪个

Surface 是更底层的"带形状/颜色/阴影的画布",CardSurface 的 Material3 语义封装(默认圆角 12dp、带 CardDefaults 配色)。经验法则:

  • 要做"符合 Material 规范的卡片"(圆角、点击涟漪、主题配色)→ 用 Card
  • 要做"非卡片形态的承载面"(如全屏背景色块、自定义形状的容器、可点击的圆形头像底)→ 用 Surface,因为它不预设圆角和配色语义。
kotlin 复制代码
// 圆形可点击头像底
Surface(
    shape = CircleShape,
    color = MaterialTheme.colorScheme.primaryContainer,
    onClick = { /* 跳个人页 */ }
) {
    Image(avatar, null, Modifier.size(48.dp).padding(4.dp), contentScale = ContentScale.Crop)
}
真实案例:Button 里直接传字符串导致点击无反馈

一个真实 bug:开发者写 Button(onClick = {}) { "提交" }(在 content lambda 里直接写了字符串字面量)。Compose 不会把裸字符串当成 Text 渲染------"提交" 是个 String 表达式,在 Composable lambda 里它不会被 UI 框架自动显示 (不像某些模板引擎)。结果按钮是空白的,且因没有 Text,无障碍读不出按钮用途。修法:必须显式写 Text("提交")

这条坑的本质:Compose 不会自动把 lambda 里的任意 Kotlin 值转成 UI ,只有 @Composable 函数调用才产生 UI。字符串、Int 这些普通值写在 lambda 里只是"表达式结果被丢弃"。

View 体系对照表

View Compose 备注
TextView Text style 是 TextStyle,不是属性
ImageView Image/Icon scaleType → contentScale
MaterialCardView Card(Material3) 圆角/阴影用 CardDefaults

原理简析 🔬

Textstyle 是一个 TextStyle 数据类,承载字号、字重、颜色、行高------和 Material Theme 的 typography 体系打通(W6 详述)。这种"样式即数据"的设计让你能在主题层统一管控文字外观,而不是在每个 Text 上散落 setTextSize

更进一步:Text 内部会基于 TextStyle 生成一个 TextLayoutResult,负责文字换行、测量、命中测试(点击选词)。TextStyle 是可被 copy 的不可变数据,所以主题系统可以"基础样式 + 局部覆盖"地组合出任意文字外观------这是命令式 setTextSize 做不到的复用性。

常见坑

  • Image 忘设 contentDescription,无障碍(TalkBack)读不出图片含义------非装饰性图片必须给描述,纯装饰用 null 但要有意为之。
  • 用大量 Spacer 手动控间距,不如在父容器用 Arrangement.spacedBy(见 2.1)。
  • Button 里直接放字符串而非 Text{}------Button 的 content 是 slot,应传 Text("文字")(见真实案例)。
  • Card 做全屏背景------Card 预设圆角和主题配色,做纯色背景块应该用 SurfaceBox(Modifier.background(...))
  • Textcolor 参数和 style 里的 color 重复设置,后者覆盖前者------统一在 style 里管颜色更清晰。

自检

想让一张网络图片"填满 160dp 高、宽全屏的框,超出部分裁掉",Image 的 contentScale 和 Modifier 怎么配?

Modifier.fillMaxWidth().height(160.dp) 确定目标框,contentScale = ContentScale.Crop 让图片等比缩放填满框并裁掉溢出部分。注意:图片内容本身(painter)还要配合 Modifier.clip(RoundedCornerShape(...)) 如果需要圆角,否则 Crop 后是直角矩形。

进阶自测 :若图片实际很宽(如 2000×200),目标框 360×160,Crop 会显示图片的哪部分?答:Crop 等比缩放使图片"填满"框------宽度方向 2000→360(缩放 0.18),高度 200→36,不够 160,所以改为高度方向 200→160(缩放 0.8),宽度 2000→1600,超出 360 的部分左右裁掉。即居中显示、左右裁切,保留图片中部竖直条带。


2.10 内在测量 Intrinsic + 布局三阶段

一句话 :Compose 布局分组合 → 布局 → 绘制 三阶段;IntrinsicSize 让你的组件能"先问子项想多大"再决定自身尺寸------这是实现"两个卡片高度取最大值对齐""分割线撑满最高项"等效果的关键,也是理解复杂自定义布局的底层。

从一个真实的别扭说起

你想做"左右两个文本,中间一条分割线,分割线高度等于左右较高的那个文本"。但布局是"先约束后测量"的,容器在测量时还不知道 子项最终多高------除非你先"探一下"子项的固有高度。IntrinsicSize 就是这套"先探后定"的机制。

没有它,你只能靠"硬编码两卡片等高"或"用固定高度",一旦一侧文本变长,布局就错位。Intrinsic 让布局能根据内容动态决策尺寸,这正是声明式 UI 相对写死尺寸的优势。

它是什么

正常布局是单趟 :父把约束给子,子回报尺寸。但有些布局需要两趟 :第一趟问"你最少/最多想要多大"(min/max intrinsic),据此定自己的尺寸,第二趟再正式测量。Modifier.width(IntrinsicSize.Max) 就是开启这趟"探测"的开关。

kotlin 复制代码
// 两个文本 + 中间分割线,分割线高度 = 两者较高者
Row(Modifier.height(IntrinsicSize.Min)) {
    Text("短", Modifier.padding(8.dp))
    Divider(Modifier.fillMaxHeight().width(1.dp)) // 撑满 Row 的 min-intrinsic 高度
    Text("这是一段<br/>换行的长文本", Modifier.padding(8.dp))
}

IntrinsicSize.Min 让 Row 先问两个 Text 的 min intrinsic 高度,取较大者作为 Row 高度,于是 fillMaxHeight() 的 Divider 正好撑满。

布局三阶段全景

#mermaid-svg-RiCkwj89qv7PgUf3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RiCkwj89qv7PgUf3 .error-icon{fill:#552222;}#mermaid-svg-RiCkwj89qv7PgUf3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RiCkwj89qv7PgUf3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RiCkwj89qv7PgUf3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RiCkwj89qv7PgUf3 .marker.cross{stroke:#333333;}#mermaid-svg-RiCkwj89qv7PgUf3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RiCkwj89qv7PgUf3 p{margin:0;}#mermaid-svg-RiCkwj89qv7PgUf3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster-label text{fill:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster-label span{color:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster-label span p{background-color:transparent;}#mermaid-svg-RiCkwj89qv7PgUf3 .label text,#mermaid-svg-RiCkwj89qv7PgUf3 span{fill:#333;color:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 .node rect,#mermaid-svg-RiCkwj89qv7PgUf3 .node circle,#mermaid-svg-RiCkwj89qv7PgUf3 .node ellipse,#mermaid-svg-RiCkwj89qv7PgUf3 .node polygon,#mermaid-svg-RiCkwj89qv7PgUf3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RiCkwj89qv7PgUf3 .rough-node .label text,#mermaid-svg-RiCkwj89qv7PgUf3 .node .label text,#mermaid-svg-RiCkwj89qv7PgUf3 .image-shape .label,#mermaid-svg-RiCkwj89qv7PgUf3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-RiCkwj89qv7PgUf3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RiCkwj89qv7PgUf3 .rough-node .label,#mermaid-svg-RiCkwj89qv7PgUf3 .node .label,#mermaid-svg-RiCkwj89qv7PgUf3 .image-shape .label,#mermaid-svg-RiCkwj89qv7PgUf3 .icon-shape .label{text-align:center;}#mermaid-svg-RiCkwj89qv7PgUf3 .node.clickable{cursor:pointer;}#mermaid-svg-RiCkwj89qv7PgUf3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RiCkwj89qv7PgUf3 .arrowheadPath{fill:#333333;}#mermaid-svg-RiCkwj89qv7PgUf3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RiCkwj89qv7PgUf3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RiCkwj89qv7PgUf3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RiCkwj89qv7PgUf3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RiCkwj89qv7PgUf3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RiCkwj89qv7PgUf3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster text{fill:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 .cluster span{color:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RiCkwj89qv7PgUf3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RiCkwj89qv7PgUf3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-RiCkwj89qv7PgUf3 .icon-shape,#mermaid-svg-RiCkwj89qv7PgUf3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RiCkwj89qv7PgUf3 .icon-shape p,#mermaid-svg-RiCkwj89qv7PgUf3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RiCkwj89qv7PgUf3 .icon-shape .label rect,#mermaid-svg-RiCkwj89qv7PgUf3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RiCkwj89qv7PgUf3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RiCkwj89qv7PgUf3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RiCkwj89qv7PgUf3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 嵌入
① 组合 Composition
② 布局 Layout
③ 绘制 Drawing
执行 @Composable

构建 UI 描述树

(不含尺寸)
约束向下传

子项测量+回报尺寸

父确定子位置
按位置画像素

background/clip/drawXxx
Intrinsic 探测:

在 ② 之前先问

子项 min/max 固有尺寸

进阶:三阶段各自"做什么、不做什么"
阶段 做什么 不做什么
组合 Composition 执行 Composable 函数、构建 UI 树(节点 + Modifier 链) 不算尺寸、不画像素
布局 Layout 约束向下传、测量子项、确定每个节点位置与尺寸 不算"是否重组"、不画像素
绘制 Drawing 按位置执行 draw 指令(background、clip、内容) 不改尺寸、不触发重组

理解这个分阶段模型,能解释很多现象:比如"改 Modifier 顺序只影响绘制/布局、不改组合结果";"重组发生在组合阶段、但布局/绘制可能因为尺寸变化被跳过(见 4.6)"。

真实案例:自定义"等高两栏"因误用 IntrinsicSize.Max 撑爆

一个真实 bug:开发者想让左右两栏等高(取较高者),写成 Row(Modifier.height(IntrinsicSize.Max))。结果 Row 高度变成"子项最多能撑多大"------对 Text 而言,max intrinsic 高度在"宽度无限"时等于单行高度(因为不换行),于是 Row 被压成单行高,长文本被裁剪。修法:改用 IntrinsicSize.Min------min intrinsic 高度是"子项在给定宽度下最少需要多高(即正常换行后的高度)",取两者较大者才是正确等高。

这条案例揭示一个反直觉点:Min 在这里反而取"较大值" 。因为 IntrinsicSize.Min 的语义是"父容器用子项的 min-intrinsic 值来定自己的尺寸",而两个 Text 的 min-intrinsic 高度就是它们正常显示所需高度,取 max 即"能装下较高那个"。

View 体系对照表

View Compose 备注
onMeasure 里先 measureChildren 再定自身 IntrinsicSize 两趟测量 机制一致,声明式写法

原理简析 🔬

(min|max)IntrinsicWidth/Height 是定义在 Measurable 上的函数:当父请求 intrinsic 时,子项返回一个"我在这个约束下最少/最多要多少"的值。这条链路让"数据驱动尺寸"在编译期不可知的情况下仍能正确布局。自定义 Layout(W10)时可以覆写这些函数提供更精准的 intrinsic 值。

补充:Intrinsic 查询不会触发子项的实际测量 (measure),只调用其 minIntrinsicHeight 等快速计算函数,所以开销比"真测一遍"小。但即便如此,它仍是"额外一趟",所以默认关闭。

常见坑

  • 在列表项(Lazy 项)里滥用 IntrinsicSize,每帧多一趟测量拖慢滚动------列表项尽量用固定/约束尺寸。
  • 以为 IntrinsicSize.Min 一定比 Max 小------它是"父询问子的 min 固有尺寸",具体取哪个要看你的对齐意图(见真实案例)。
  • IntrinsicSize 当"让内容自适应"的万能药------它只解决"父尺寸依赖子内容尺寸"这一个特定问题。

自检

"两个文本取较高者高度"为什么用 IntrinsicSize.Min 而非 Max?

Row 的 height(IntrinsicSize.Min) 表示"Row 的高度 = 子项 min intrinsic 高度中的最大值"。两个 Text 的 min intrinsic 高度分别是各自的"最少需要多高才能完整显示"(考虑换行)。取两者较大者,Row 就能完整容纳较高的那个文本;Divider 用 fillMaxHeight 撑满这个高度。如果用 Max,会取子项"最多能撑多大",对 Text 而言通常是单行无限宽的情况,不符合预期。

进阶自测 :把一个 Text 的 maxLines = 1,另一个正常换行,用 IntrinsicSize.Min 等高,结果如何?答:maxLines=1 的 Text 其 min intrinsic 高度 = 单行高(它可能溢出被省略号截断),另一个 = 多行高。Row 取两者 max = 多行高,于是单行 Text 那侧下方留白、Divider 撑满多行高。如果希望两栏严格按"各自真实显示高度"对齐,需保证两侧都不强制截断。


本章小结

这一章你打下了 Compose 布局的地基:

  1. 三大布局:Column/Row(线性)、Box(堆叠+对齐)、ConstraintLayout(深嵌套性能救星)------默认不引入,瓶颈处才用。
  2. Modifier 是顺序敏感的链式对象:padding/background 的先后、clip/background 的先后,决定了完全不同的视觉效果;口诀"padding→clip→background→clickable"。
  3. size 族是对约束的不同回应:fillMax 取上限、wrapContent 取内容、required 强制、sizeIn 设限。
  4. 作用域型 Modifier(weight/align/matchParentSize)由类型系统保护:放错地方编译就红,这是 Compose 相对 View 的硬性安全保障。
  5. offset 不改测量、padding 改测量:动画位移用 offset/graphicsLayer,间距用 padding;透明度动画用 graphicsLayer 走 GPU 合成更快。
  6. Scaffold 自动避让栏位 :别忘了把 innerPadding 加到内容根(注意别多层 Scaffold 叠加)。
  7. 基础组件参数即样式 ;间隔首选 Arrangement.spacedBy 而非散落的 Spacer;样式是独立数据对象,可被主题复用。
  8. 内在测量是"先探后定"的两趟机制(P2):解决"父尺寸依赖子内容"的特殊布局;Min/Max 语义反直觉,用错会撑爆或截断。

下一章进入列表与 Lazy------这是 Compose 相对 RecyclerView 思维定式最大的转变点,也是日常最高频的性能战场。


本章自检(答案折叠)

Q1:Box 里一个子项 fillMaxSize()、一个 matchParentSize(),分别在什么条件下生效?父 Box 若改成 wrapContentHeight 会怎样?

fillMaxSize() 让子项"填满父 Box 给的最大约束",会反推 父 Box 撑大(若父是 wrapContent)。matchParentSize() 让子项"跟随父 Box 已确定的尺寸",不反推 父。若父 Box 是 wrapContentHeight,fillMaxSize 的子项会试图把父撑到父的父给的最大高度(可能全屏),而 matchParentSize 的子项跟随父------父此时由其他内容(或自身 0)决定,matchParentSize 子项可能塌成 0 高。结论:做遮罩/覆盖层,永远优先 matchParentSize。
Q2:为什么 Column 里给子项用 fillMaxWidth 无效?

Column 的主轴是竖直方向,交叉轴是水平方向。fillMaxWidth 作用于交叉轴(水平),在 Column 里是有效的 (让子项水平填满)。你可能想问的是 fillMaxHeight 在 Column 里"为什么有时不撑开"------那是因为 Column 主轴高度若由内容决定(wrapContent),没有"剩余空间"给子项 fill,需要父 Column 本身有确定高度(如 fillMaxSize)才行。
Q3:weight(1f) 在 Row 里,若 Row 宽度是 wrapContent,会怎样?

Row 宽度由内容决定时,没有"剩余空间"可分配,weight 子项会退化成"取自身内容宽度",weight 比例不生效。weight 只有在父容器在该轴上有确定或可计算的剩余空间(如 fillMaxWidth 的 Row)时才发挥作用。
Q4:一个 Row 里 `Text("A", Modifier.weight(1f, fill = false))` 和 `Text("B", Modifier.weight(1f))`,两者宽度表现有何不同?

两者权重都是 1,但 A 的 fill = false 表示"分到剩余空间后只取内容宽、不撑满",B 默认 fill = true 会撑满分配到的宽度。结果:B 占满右半屏,A 只占自己文字宽、左对齐贴在左侧,A、B 之间有空白。这正是"等权重但各自包裹内容"的实现方式。

相关推荐
alexhilton9 小时前
从零开始的架构测试套件
android·kotlin·android jetpack
天空之城--2 天前
Android Jetpack Compose 状态管理浅析
android·android jetpack
我命由我123453 天前
Android Compose 开发,使用 ConstraintLayout,但是引入的是旧的 ConstraintLayout
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
我命由我123453 天前
Android 开发问题:android.permission.CAMERA...duplicated with element declared at
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
我命由我123453 天前
Android 控件 - ListAdapter
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
天空之城--3 天前
Android行业一周动态:编码趋势与行业资讯汇总
android·性能优化·架构·kotlin·android jetpack
我命由我123454 天前
Compose Codelab 学习 - Jetpack Compose 中的状态
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
撩得Android一次心动4 天前
Jetpack Compose 知识点整理1【个人用】
android·学习·kotlin·android jetpack·compose
事圆则缓5 天前
MVVM 的理解与设计
android·android jetpack