Skip to content

输入框 — TextField / TextArea ​

底层是 Material3 OutlinedTextField(带边框的那个)的薄封装:容器底、描边、浮动标签、 错误态、标签在边线上的缺口,全部交给 M3 —— 组件库这边不再自绘任何一笔。

组件库只做六件事:

  1. 形状固定 AppShapes.textField(12dp 圆角) —— M3 描边式默认 4dp,这里显式盖掉;
  2. 字阶全部压回自绘版的取值 —— 输入文字与 placeholder 是 bodyMedium(14sp), 标签在 bodyMedium ↔ labelSmall(11sp) 之间随浮起进度插值。M3 的默认是 bodyLarge(16sp), 而且 placeholder 那个 bodyLarge 写死在它内部、标签更是逐帧 lerp,三处都得显式给 style;
  3. 配色收口 —— M3 的 OutlinedTextFieldDefaults.colors(...) 有 46 个参数, 组件库把用得到的那些映射成自己的 11 字段 TextFieldColors,调用方只面对后者;
  4. placeholder 也会浮起 —— 只给 placeholder 不给 label 时,它自己就充当标签;
  5. actions 插槽 —— 右侧除了图标按钮,还能塞任意内容(如「获取验证码」按钮);
  6. 单行可见高度钉回 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(单行)有(多行右下角不塞动作按钮,免得挡到文字):

参数类型默认值说明
valueString—输入值,状态外置
onValueChange(String) -> Unit—值变化回调
modifierModifierModifier修饰符。作用于整块(输入框 + 下方错误提示),内部会 fillMaxWidth()。横向并排的弹性宽度就写 Modifier.weight(1f) —— 它落在整块上(搜索框那一排都是这么用的)
placeholderString""提示文字。空串表示不显示;没给 label 时它自己就是标签(会浮起)
labelString?null浮动标签(可选)。未聚焦且为空时停在框内(等效占位符),聚焦或有内容后缩小浮起
leadingIconImageVector?null左侧图标(20dp,非按钮,不可点)
leadingIconTintColor?null左侧图标颜色;null 取 colors.leadingIconTint
trailing(@Composable () -> Unit)?null右侧插槽,只放一个图标按钮(密码眼睛 / 置空等)。组件把槽钉成 40dp 见方并垂直居中,所以放什么都不会改变输入框高度;null 不占位
actions(@Composable () -> Unit)?null(仅 TextField) 右侧动作插槽,渲染在 trailing 右边(null 完全不占位)。按钮这类非图标动作用它
readOnlyBooleanfalse只读:仍可聚焦、可选中复制
enabledBooleantrue禁用:整体灰掉、不可聚焦
minLinesInt3(仅 TextArea) 最小行数
maxLinesInt6(仅 TextArea) 最大行数
focusRequesterFocusRequester?null主动聚焦用(内部挂到 OutlinedTextField 的 modifier 上)
keyboardOptionsKeyboardOptionsKeyboardOptions.Default键盘类型 / IME 动作
keyboardActionsKeyboardActionsKeyboardActions.DefaultIME 按键回调
visualTransformationVisualTransformationVisualTransformation.None视觉转换(掩码等)
isErrorBooleanfalse错误态:描边 / 标签 / 图标 / 提示统一转 colors.errorColor
errorMessageString?null错误提示。非空(或非空白)时显示在输入框下方,恒用错误色;不改变框高(组件自己渲染,不走 M3 的 supportingText)
colorsTextFieldColorsTextFieldDefaults.colors()配色,见下

TextFieldColors(TextFieldDefaults.colors(...)) ​

配色是本组件唯一的对外入口,调用方不需要(也拿不到)M3 的 OutlinedTextFieldDefaults。

字段默认值说明
filledContainerColorColor.Transparent单行容器色。给了底色又让标签浮起会露出页面背景(见「说明」);只有 label 与 placeholder 都没给时才安全
outlinedContainerColorColor.Transparent多行容器色,同上
textColoronSurface输入文字色
disabledTextColoronSurface 38%禁用态文字 / 图标色
placeholderColoronSurfaceVariant占位符色;也是未聚焦标签(停在框内时)的色
cursorColorprimary光标色
focusedBorderColorprimary聚焦描边色,同时也是聚焦标签色
unfocusedBorderColoroutline未聚焦描边色(单行 / 多行都生效)
errorColorerror错误态:描边 / 标签 / 图标 / 内联提示统一用它
leadingIconTintonSurfaceVariant左侧图标色
trailingIconTintonSurfaceVariant右侧图标色

要点 ​

  • 单行高度钉死 48dp(可见框):M3 的固有度量是 56dp(OutlinedTextFieldDefaults.MinHeight), 组件内部用外层 Modifier.height 压掉 —— 依据是 Compose defaultMinSize 的语义 (传入的 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 封装当初被撤掉的三条,这一版逐条处理:
    1. 字阶不再是 bodyLarge —— 输入文字与 placeholder 显式传 bodyMedium; 标签则在 label lambda 内部读 LocalTextStyle 拿到 M3 的浮起进度, 再插我们自己的 bodyMedium ↔ labelSmall(写死一个 token 会让标签浮起时"啪"地跳一档);
    2. 高度压回 48dp(可见框) —— 用外层 Modifier.height 覆盖 M3 的 56dp, 并补回 M3 给标签留的半个字高(报错时也钉 —— 早期版本写的是「带错误提示时不钉」, 结果错误一出现框就从 48 跳到 56,2026-10-12 已修);
    3. 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,两个组件一起变。