外观
输入框 — TextField / TextArea
底层是 Material3 OutlinedTextField(带边框的那个)的薄封装:容器底、描边、浮动标签、 错误态、标签在边线上的缺口,全部交给 M3 —— 组件库这边不再自绘任何一笔。
组件库只做六件事:
- 形状固定
AppShapes.textField(12dp 圆角) —— M3 描边式默认 4dp,这里显式盖掉; - 字阶全部压回自绘版的取值 —— 输入文字与 placeholder 是
bodyMedium(14sp), 标签在bodyMedium↔labelSmall(11sp) 之间随浮起进度插值。M3 的默认是bodyLarge(16sp), 而且 placeholder 那个bodyLarge写死在它内部、标签更是逐帧 lerp,三处都得显式给 style; - 配色收口 —— M3 的
OutlinedTextFieldDefaults.colors(...)有 46 个参数, 组件库把用得到的那些映射成自己的 11 字段TextFieldColors,调用方只面对后者; placeholder也会浮起 —— 只给placeholder不给label时,它自己就充当标签;actions插槽 —— 右侧除了图标按钮,还能塞任意内容(如「获取验证码」按钮);- 单行可见高度钉回 48dp —— M3 固有 56dp,用外层
Modifier.height压掉(M3 自己在MinHeight的注释里就说可以这么覆盖),并补回 M3 给浮动标签留的半个字高(见「要点」)。报错时也是这个 高度 —— 错误提示由组件自己排在框下面,不参与框的高度预算。
| 组件 | 形态 | 用在哪 |
|---|---|---|
TextField | 透明底 + 1dp 描边 + 12dp 圆角 | 搜索框、表单单行项 |
TextArea | 透明底 + 1dp 描边 + 12dp 圆角 | 备注、描述、大段文本编辑器 |
两个形态的底色都是透明的 —— 这不是偷懒,是 M3 描边式的硬约束: 它给「在边框上开洞」的浮动标签(TextFieldLabelPosition.Cutout)用的实现是 Modifier.outlineCutout, 也就是 clipRect(…, ClipOp.Difference) { drawContent() } —— 把整个容器(底色 + 描边一起) 按标签宽度裁掉一块矩形,露出来的是页面背景。容器一旦有底色,标签浮起时底色上就会出现一块 「白底」;官方 OutlinedTextField 容器默认透明,正是这个原因。详见下面「说明」。
两种形态拆成两个组件,不用 singleLine 参数切换。两者都常驻一圈描边: 未聚焦是 1dp outline,聚焦转 primary 并加粗到 2dp,错误态转 error。
标签(label,或只给的 placeholder)平时停在输入区里当占位符,聚焦或有内容后缩小、 浮起到顶部边线上 —— M3 会按标签实测宽度在边线上开出缺口。
基本用法
单行搜索框(只给 placeholder,它自己会浮起):
kotlin
var query by remember { mutableStateOf("") }
TextField(
value = query,
onValueChange = { query = it },
placeholder = "搜索",
leadingIcon = Phosphor.MagnifyingGlass,
)清除按钮组件不自带,自己写进 trailing 插槽(槽里只放一个图标按钮):
kotlin
TextField(
value = query,
onValueChange = { query = it },
placeholder = "搜索",
leadingIcon = Phosphor.MagnifyingGlass,
trailing = {
IconButton(onClick = { query = "" }) {
Icon(Phosphor.XCircle, contentDescription = "清空")
}
},
)想让它只在有内容时出现,就把整个槽做成条件的:
kotlin
trailing = if (query.isNotEmpty()) {
{ IconButton(onClick = { query = "" }) { Icon(Phosphor.XCircle, contentDescription = "清空") } }
} else {
null
},带浮动标签:
kotlin
TextField(
value = email,
onValueChange = { email = it },
label = "邮箱",
)未聚焦且为空时标签停在框内当占位符;聚焦或有内容后标签浮起。 label 与 placeholder 一般只给一个 —— 两个都给时,placeholder 只在聚焦或已有内容后 出现在标签原来的位置(否则会和浮起的标签叠在一起,所以未聚焦不显示)。
右侧放一个非图标动作(actions 插槽):
kotlin
TextField(
value = email,
onValueChange = { email = it },
label = "邮箱",
actions = {
TertiaryButton(
text = if (countdown > 0) "${countdown}s" else "获取验证码",
onClick = { sendCode() },
enabled = countdown == 0,
size = ButtonSize.SMALL,
)
},
)密码框(掩码 + 眼睛开关,走 trailing 插槽):
kotlin
var visible by remember { mutableStateOf(false) }
TextField(
value = password,
onValueChange = { password = it },
label = "密码",
trailing = {
IconButton(onClick = { visible = !visible }) {
Icon(
imageVector = if (visible) Phosphor.EyeSlash else Phosphor.Eye,
contentDescription = if (visible) "隐藏密码" else "显示密码",
)
}
},
visualTransformation = if (visible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
)插槽里不用自己传图标色:M3 已经为这个槽提供了 LocalContentColor (= colors.trailingIconTint,错误态自动转 error,禁用态转 disabledTextColor); 也不需要自己钉尺寸 —— 用 M3 的 IconButton 就有 48dp 触控区。
表单校验失败(错误提示由组件渲染在框下方,不改变框高):
kotlin
TextField(
value = email,
onValueChange = { email = it },
label = "邮箱",
isError = !isValid,
errorMessage = "请输入有效的邮箱地址".takeIf { !isValid },
)多行:
kotlin
TextArea(
value = note,
onValueChange = { note = it },
label = "备注",
minLines = 4,
maxLines = 10,
)弹窗打开就聚焦:
kotlin
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
TextField(
value = name,
onValueChange = { name = it },
label = "名称",
focusRequester = focusRequester,
)参数
TextField(单行)与 TextArea(多行)
两者参数表基本一致 —— 加粗的两项只有 TextArea 有,反过来 actions只有 TextField(单行)有(多行右下角不塞动作按钮,免得挡到文字):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | String | — | 输入值,状态外置 |
onValueChange | (String) -> Unit | — | 值变化回调 |
modifier | Modifier | Modifier | 修饰符。作用于整块(输入框 + 下方错误提示),内部会 fillMaxWidth()。横向并排的弹性宽度就写 Modifier.weight(1f) —— 它落在整块上(搜索框那一排都是这么用的) |
placeholder | String | "" | 提示文字。空串表示不显示;没给 label 时它自己就是标签(会浮起) |
label | String? | null | 浮动标签(可选)。未聚焦且为空时停在框内(等效占位符),聚焦或有内容后缩小浮起 |
leadingIcon | ImageVector? | null | 左侧图标(20dp,非按钮,不可点) |
leadingIconTint | Color? | null | 左侧图标颜色;null 取 colors.leadingIconTint |
trailing | (@Composable () -> Unit)? | null | 右侧插槽,只放一个图标按钮(密码眼睛 / 置空等)。组件把槽钉成 40dp 见方并垂直居中,所以放什么都不会改变输入框高度;null 不占位 |
actions | (@Composable () -> Unit)? | null | (仅 TextField) 右侧动作插槽,渲染在 trailing 右边(null 完全不占位)。按钮这类非图标动作用它 |
readOnly | Boolean | false | 只读:仍可聚焦、可选中复制 |
enabled | Boolean | true | 禁用:整体灰掉、不可聚焦 |
minLines | Int | 3 | (仅 TextArea) 最小行数 |
maxLines | Int | 6 | (仅 TextArea) 最大行数 |
focusRequester | FocusRequester? | null | 主动聚焦用(内部挂到 OutlinedTextField 的 modifier 上) |
keyboardOptions | KeyboardOptions | KeyboardOptions.Default | 键盘类型 / IME 动作 |
keyboardActions | KeyboardActions | KeyboardActions.Default | IME 按键回调 |
visualTransformation | VisualTransformation | VisualTransformation.None | 视觉转换(掩码等) |
isError | Boolean | false | 错误态:描边 / 标签 / 图标 / 提示统一转 colors.errorColor |
errorMessage | String? | null | 错误提示。非空(或非空白)时显示在输入框下方,恒用错误色;不改变框高(组件自己渲染,不走 M3 的 supportingText) |
colors | TextFieldColors | TextFieldDefaults.colors() | 配色,见下 |
TextFieldColors(TextFieldDefaults.colors(...))
配色是本组件唯一的对外入口,调用方不需要(也拿不到)M3 的 OutlinedTextFieldDefaults。
| 字段 | 默认值 | 说明 |
|---|---|---|
filledContainerColor | Color.Transparent | 单行容器色。给了底色又让标签浮起会露出页面背景(见「说明」);只有 label 与 placeholder 都没给时才安全 |
outlinedContainerColor | Color.Transparent | 多行容器色,同上 |
textColor | onSurface | 输入文字色 |
disabledTextColor | onSurface 38% | 禁用态文字 / 图标色 |
placeholderColor | onSurfaceVariant | 占位符色;也是未聚焦标签(停在框内时)的色 |
cursorColor | primary | 光标色 |
focusedBorderColor | primary | 聚焦描边色,同时也是聚焦标签色 |
unfocusedBorderColor | outline | 未聚焦描边色(单行 / 多行都生效) |
errorColor | error | 错误态:描边 / 标签 / 图标 / 内联提示统一用它 |
leadingIconTint | onSurfaceVariant | 左侧图标色 |
trailingIconTint | onSurfaceVariant | 右侧图标色 |
要点
- 单行高度钉死 48dp(可见框):M3 的固有度量是 56dp(
OutlinedTextFieldDefaults.MinHeight), 组件内部用外层Modifier.height压掉 —— 依据是 ComposedefaultMinSize的语义 (传入的 min 不为 0 时不再套用默认值),M3 自己在那个常量的注释里也明说可以这么覆盖。 ⚠️ 压的时候必须补回 M3 给标签留的半个字高:有标签时 M3 会自动加一道topPaddingForLabelCutout(值 =bodySmall.lineHeight / 2≈ 8dp),那道 padding 加在传给它的 modifier 里面,所以Modifier.height(H)量到的是H + 半个标签—— 只写 H 的话可见框会矮掉 那半个字高,输入区被挤扁、光标被裁。组件里是按SingleLineHeight + labelCutoutReserve()钉的。 报错时也钉(2026-10-12 修):M3 的supportingText和输入框在同一个 Column 里,一旦用它, 整条高度就不再由我们钉的那个值说了算 —— 错误一冒出来 M3 就回到它自己的 56dp,可见框从 48 跳到 56, 看起来就是「文本框被撑大」。所以组件不传supportingText,错误文字由自己渲染在框下面。 代价:错误行出现时框下面的内容会整体下移约 22dp(那一行字本身要占地方)—— 这是标准行为; 要做到「连这一下也不动」只能永远预留那一行(每两个输入框之间都多出 ~22dp 空档),当前没这么做。 - 多行不钉高度,交给
minLines/maxLines。 - 浮动标签跨在顶边线上(上下各露半个字高):外层别用
Modifier.clip裁它, 上一个控件也别紧贴,留几 dp 空隙。 - 宽度撑满:组件内部调了
fillMaxWidth()(沿用自绘版的契约,各页面都按撑满使用)。 M3 自己只给defaultMinSize(minWidth = 280.dp),不补这一句输入框会缩到 280dp。 - 标签只有一套来源:给
label,或只给placeholder—— 两者都会浮起。 两个都给时,placeholder退回占位符语义(聚焦后才出现)。 - 右侧只有一个插槽
trailing+ 一个动作槽actions(旧的trailingIcon1/2那六个参数已删除)。 自定义按钮一律写进trailing,槽内直接用 M3 的IconButton+Icon即可。 trailing槽被钉成 40dp 见方、垂直居中:M3 的IconButton自带minimumInteractiveComponentSize= 48dp,而 M3 的calculateHeight是按图标的实测高度 参与高度计算的(maxOf(leadingHeight, trailingHeight, middleSectionHeight))—— 钉死之后, 「有没有图标 / 图标多大」都改不了输入框高度(否则图标会把输入框顶高)。 代价是触控区 40dp(Material 建议 48dp)。- 一个框只放一个图标:M3 的 48dp 最小触控区会把两个图标顶得很开,多按钮场景请用
actions或另想办法 —— 组件不接管置空,邮箱框放置空、密码框放眼睛,各放各的。 - 图标色:未聚焦取
leadingIconTint/trailingIconTint;禁用转disabledTextColor; 错误态转errorColor。trailing槽内不传色也能拿到(M3 提供了LocalContentColor)。 - 错误提示由组件自己渲染(
bodySmall+colors.errorColor,左起 16dp),排在框下面、与框同宽, 不参与框的高度。isError仍然交给 M3(描边 / 标签 / 图标 / 光标转错误色)。 readOnly与enabled = false不是一回事:前者仍可聚焦、可选中复制(适合展示可复制的值), 后者整体灰掉且不可聚焦。- 输入文字关掉了中文字形上下不对称的内边距(
PlatformTextStyle(includeFontPadding = false)), 中文在框里才是真正垂直居中。
说明
- 为什么改成封装 M3:早先的实现是拿
BasicTextField自绘容器 / 描边 / 浮动标签 (连描边缺口都手算)。自绘的好处是尺寸与字阶完全可控,代价是所有视觉细节都得自己维护。 2026-10-12 YG 要求换成封装 M3「带边框的那个」,于是整篇改为OutlinedTextField。 - 上一版 M3 封装当初被撤掉的三条,这一版逐条处理:
- 字阶不再是
bodyLarge—— 输入文字与 placeholder 显式传bodyMedium; 标签则在 label lambda 内部读LocalTextStyle拿到 M3 的浮起进度, 再插我们自己的bodyMedium↔labelSmall(写死一个 token 会让标签浮起时"啪"地跳一档); - 高度压回 48dp(可见框) —— 用外层
Modifier.height覆盖 M3 的 56dp, 并补回 M3 给标签留的半个字高(报错时也钉 —— 早期版本写的是「带错误提示时不钉」, 结果错误一出现框就从 48 跳到 56,2026-10-12 已修); - 46 个配色参数留在组件库内部 —— 对外仍是 11 个字段的
TextFieldDefaults.colors(...)。
- 字阶不再是
- 容器底色为什么必须是透明的:M3 的浮动标签用
TextFieldLabelPosition.Cutout, 实现在Modifier.outlineCutout里,就是clipRect(…, ClipOp.Difference) { drawContent() }—— 它按标签宽度把整个容器(底色 + 描边一起)裁掉一块矩形,露出来的是页面背景。 于是容器一旦有底色,标签浮起时底色上就多出一块「白底」(页面背景透出来)。 官方OutlinedTextField容器默认透明,正是这个原因 —— 「有底色 + 浮动标签」在 M3 描边式里是互斥的。 本组件默认把两个容器色都设成透明;要填色,请确认该输入框不给label也不给placeholder(标签宽度为 0 时outlineCutout走 else 分支,不裁任何东西)。 focusRequester挂在OutlinedTextField的modifier上(M3 没有对应参数); 组件不另外提供onFocusChanged。- 形状不开放参数:要用别的圆角请改
AppShapes.textField,两个组件一起变。