用了那么久的Compose,不得不说,TextField组件真是难用而且还有bug。
环境:
- Compose版本:composeBom = "2025.07.00"
- 运行环境:为mumu模拟器12
痛点1:默认形状怪异
TextField是基于MD3的组件,默认风格是MD3的,所以默认形状是顶部圆角、底部尖角的形状:
修改前:
其实这个问题还算这个好解决,可以利用TextField组件的shape参数来修改,比如想改成四个角均为圆角可以这样写:
TextField(
//......
shape = RoundedCornerShape(10.dp)
//......
)
修改后:
问题2:输入焦点指示器
大家有没有留意到修改后的截图,输入框底边有条横线?实际上这条横线表示的是输入焦点是否在该控件上。 初学者可能会以为是bug (其实我也觉得是bug或者设计失误)从而弃用该组件,而基于BasicTextField自定义一个TextField。其实这是没必要的,好在Compose官方给我们留了一道口子,可以通过修改colors参数来变相绕过这个问题:
TextField(
//......
shape = RoundedCornerShape(10.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
//......
)
这里我们可以让这个输入焦点指示器的颜色改成透明。但实际上,输入焦点指示器的线条长度,还是没变,只是看不见了,以损失焦点指示器的特性为代价,实现了四角圆角:
起码看起来还算可以。不过如果使用的是BasicTextField,还是有办法改变焦点指示器的线条长度的,但比较麻烦,后面另外再说这种解决方法(如无必要,勿增实体。PS: 😭加班搞不完)。但问题还很多!!
问题3:输入光标与输入字符错位
当输入了字符并且输入光标显示出来后,问题更明显了:
看到了吗?输入光标和输入字符明显错位了。输入光标下方很长,上方几乎没有长度。甚至字符A都不见了个头顶。但有的小伙伴可能会好奇,为什么我的TextField不会出现这种问题?
看,大写字母完美垂直居中,小写字母完美基线对齐!为啥有的小伙伴的TextField,就像上面那个歪瓜裂枣??玄学了。
仔细观看就会发现,两个输入框的高度不一致!!,实际上正常的TextField,用的是默认高度,也就是没有用Modifier改过height的。
TextField(
modifier = Modifier.width(423.dp), //.height(100.dp),
//......
)
一旦自定义了输入框的高度,就有问题了,比方说我把高度改得比默认高度要高:
TextField(
modifier = Modifier.width(423.dp).height(100.dp),
//......
)
就会变成这样:
大写字母虽然还是垂直居中,小写字母也是基线对齐,但整个文本和光标并不相对于输入框垂直居中。
又或者这样,把高度改得比默认高度要小:
TextField(
modifier = Modifier.width(423.dp).height(70.dp),
//......
)
看出问题了吧,光标和字符的顶部被裁剪了!!所以不对称。
为什么会这样?这其实是由于TextField的TextFieldDefaults.DecorationBox装饰框自带一个写死了16dp的垂直方向的padding引起的,对于带label的TextField,这个padding是8dp:
并且TextField还有默认最小高度56dp:
这是一个很简单的算术问题,假设改过Typography中的bodyLarge这个TextStyle,比如:
而TextField默认的style用的就是bodyLarge, 那么在不计算行高的情况下,这里的fontSize是36.sp, 高度是70.dp, 垂直方向的padding是16.dp, 实际上留给字符的垂直空间,只有(70-16*2).dp = 38.dp,更要命的是,这个padding是写死的。所以用TextField根本无法修改padding。这条路就彻底封死了。。那怎么办?要么字体改小,要么用BasicTextField自定义。
方案1. 基于 BasicTextField 自定义 TextField
TODO: 这个方案太麻烦了,埋个坑,以后开专题讲这个方案。
方案2. 字体改小
自定义BasicTextField工程量略大,先试试字体改小到30.sp,看看效果:
val password = rememberSaveable { mutableStateOf("") }
val bodyMedium = remember {
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 30.sp,
lineHeight = 30.sp,
platformStyle = PlatformTextStyle(includeFontPadding = false),
)
}
SettingsTheme {
Box(modifier = Modifier.background(Color.Black).padding(100.dp), contentAlignment = Alignment.Center) {
TextField(
modifier = Modifier.width(423.dp).height(70.dp),
value = password.value,
shape = RoundedCornerShape(10.dp),
textStyle = bodyMedium,
onValueChange = {
password.value = it
},
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
)
}
}
看起来勉强还行。文本和光标被裁剪的问题看起来好像解决了,至少不那么明显了,其实还是没有解决的。 文本行一眼看上去并不是那么垂直居中。
但这其实只是因为中英文的行高差异问题,看,假设增加了中文输入字符:
看,还是很垂直居中的。但这里又引入了TextField的另一个坑!!
问题4:行高引发的灾难1——Text组件在单行的纯英文内容和中文内容,实际高度不一致。
这里先看修改前的代码和图示:
val bodyMedium = remember {
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 30.sp,
lineHeight = 30.sp * 1.5
)
}
SettingsTheme {
Row (modifier = Modifier.background(Color.Black).padding(100.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
text = "密码",
style = bodyMedium,
modifier = Modifier.randomYellowBg()
)
Text(
text = "password",
style = bodyMedium,
modifier = Modifier.randomYellowBg()
)
}
}
看,左右两边各一个相同的Text,但是相同的字号,相同的行高,但实际占用高度就是不一致。
问题到底出在哪里呢?答案是:字体。默认情况下使用的是 fontFamily 是 FontFamily.Default,它使用的是系统字体列表,在一款字体里找不到相关字符,就会去fallback里找其它包含对应字符的字体。既然不是同一款字体,在使用英文和使用中文时,就会有不一样的实际行高表现。因此解决办法也很简单:
先添加一个字体资源,这里使用思源黑体简中:
再添加自定义字体族:
最后使用该字体族:
预览效果:
几乎完美!!
问题5:行高引发的灾难2——TextField组件的输入光标与placeholder的错位
经常会有这种需求:当输入为空时,需要默认显示一些文字来提示用户。这个时候就需要用到placeholder参数。
上图大家所看到的“密码”这其实是一个placeholder,
但,大家仔细看,“密码”两个字和输入光标有错位,输入光标上面长,下面短!!!
WTF!!!这是要逼死强迫症啊!!
为了更好地理解问题,需要完整的代码:
val password = rememberSaveable { mutableStateOf("") }
val bodyMedium = remember {
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 30.sp,
lineHeight = 30.sp * 1.5,
)
}
SettingsTheme {
Box (modifier = Modifier.background(Color.Black).padding(100.dp), contentAlignment = Alignment.Center) {
TextField(
modifier = Modifier.size(423.dp, 70.dp),
value = password.value,
shape = RoundedCornerShape(10.dp),
textStyle = bodyMedium,
onValueChange = {
password.value = it
},
placeholder = {
Text(
text = "密码",
style = bodyMedium
)
} ,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
)
}
}
你可能已经猜到了导致这个问题的关键代码在哪里了,就是行高:
lineHeight = 30.sp * 1.5,
为了让问题更加明显,我稍微改变了一下颜色配置:
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = InputContainerColor,
unfocusedContainerColor = InputContainerColor,
focusedPlaceholderColor = InputPlaceholderColor,
unfocusedPlaceholderColor = InputPlaceholderColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = Color.White
),
现在文本框背景是灰色的。只要删掉1.5倍行高的代码,就会一切正常:
但假如不能删掉1.5倍行高的代码呢?你会发现还有更多的问题,比如下图:左右两个TextField,一个输入了“密码”, 一个没输入,这里我把背景设为白色,用屏幕尺子来测量:
可以很明显发现,左侧输入了“密码”的和右侧的placeholder,高度不一致,令人头大。。。 明明用的是相同的字体配置,这都能对不齐。。。难道除了基于BasicTextField自定义TextField之外,就没有别的办法了吗?
你好,有奇技淫巧就是。。。调整基线偏移。具体来说,就是在字体配置中,增加基线的偏移量,在TextStyle的参数中增加baselineShift的配置,但具体的参数值,需要自己慢慢调:
TextStyle(
//......
baselineShift = BaselineShift(0.17f)
//......
)
效果还是不错的:
完整代码如下:
val password = rememberSaveable { mutableStateOf("") }
val bodyMedium = remember {
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 30.sp,
lineHeight = 30.sp * 1.5,
)
}
SettingsTheme {
Row (modifier = Modifier.background(Color.White).padding(100.dp), verticalAlignment = Alignment.CenterVertically) {
TextField(
modifier = Modifier.size(100.dp, 70.dp),
value = password.value,
shape = RoundedCornerShape(10.dp),
textStyle = bodyMedium,
onValueChange = {
password.value = it
},
placeholder = {
Text(
text = "密码",
style = bodyMedium.copy(
baselineShift = BaselineShift(0.17f)
)
)
} ,
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = InputContainerColor,
unfocusedContainerColor = InputContainerColor,
focusedPlaceholderColor = InputPlaceholderColor,
unfocusedPlaceholderColor = InputPlaceholderColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = Color.White
),
)
TextField(
modifier = Modifier.size(100.dp, 70.dp),
value = "",
shape = RoundedCornerShape(10.dp),
textStyle = bodyMedium,
onValueChange = {
},
placeholder = {
Text(
text = "密码",
style = bodyMedium.copy(
baselineShift = BaselineShift(0.17f)
)
)
} ,
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = InputContainerColor,
unfocusedContainerColor = InputContainerColor,
focusedPlaceholderColor = InputPlaceholderColor,
unfocusedPlaceholderColor = InputPlaceholderColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = Color.White
),
)
}
}
TextField的行高配置在最大行数为1行时导致输入跳动
但是,行高的配置始终是个隐患。假设你要求指定maxLines = 1, 就会出现一个非常诡异的问题:输入跳动。 有问题的代码如下(重点看第7行和第41行):
val password = rememberSaveable { mutableStateOf("") }
val bodyMedium = remember {
TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 30.sp,
lineHeight = 30.sp * 1.5, //重点
)
}
SettingsTheme {
Box (modifier = Modifier.background(Color.White).padding(100.dp), contentAlignment = Alignment.Center) {
TextField(
modifier = Modifier.size(100.dp, 70.dp),
value = password.value,
shape = RoundedCornerShape(10.dp),
textStyle = bodyMedium,
onValueChange = {
password.value = it
},
placeholder = {
Text(
text = "密码",
style = bodyMedium.copy(
baselineShift = BaselineShift(0.17f)
)
)
} ,
colors = TextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedContainerColor = InputContainerColor,
unfocusedContainerColor = InputContainerColor,
focusedPlaceholderColor = InputPlaceholderColor,
unfocusedPlaceholderColor = InputPlaceholderColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = Color.White
),
maxLines = 1 //重点
)
}
}
这段代码实际上只是增加了maxLines = 1 这个参数设置。但是在输入第一个中文字符的一瞬间,就会上下跳动!!
由于这里用gif图,帧率不高,所以不是很明显,但肉眼直接看的时候,是非常明显的。 一旦删掉 maxLines = 1 这一行,或者maxLines大于1,就不会出现这个问题:
或者删掉 lineHeight = 30.sp * 1.5 的同时,也删掉基线偏移,也不会跳动:
但是删掉了基线偏移,又不垂直居中了。死循环了属于是。。。。折腾半天回到原地。。。。难道除了基于BasicTextField自定义TextField以外,真的没有别的办法了吗???
有的人可能会觉得,这个跳动可能是因为placeholder和输入内容之间的高低差引起的。为了证明并非因为高低差引起的,这里把placeholder的Text的文本颜色设为透明:
可以看到没有设置maxLines时,输入文本并不会跳动。一旦设置了maxLines = 1, 问题就会出现:
把maxLines = 1 改成 singleLine = true ,问题一样存在。甚至弹跳的方向和垂直居中的位置都变了:
难道除了基于BasicTextField自定义TextField这个方案,其它方案没办法同时满足 maxLines = 1 或者singleLine = true 并且设置了行高和基线偏移吗?
有的,还是基线偏移。具体来说,就是输入文本和Placeholder同时设置基线偏移,并且singleLine = true:
但由于设置maxLines时的起跳方向是向上的,不同于singleLine时的起跳方向向下,所以输入文本的基线偏移方向要设为负数,并且具体的数值还要另外调:
总结
Jetpack Compose 的TextField和Text的坑还是挺多的,bug也多。想要避开TextField的坑,尽可能做到:
- 用默认的参数,特别是输入框高度、行高、默认字体配置。
- 有的问题也不全是Compose引起的,而是系统自带字体引起的。可以的话,尽可能app内嵌字体。避免由于字体fallback导致中英文的差别。
- 行高、粗体要慎重设置。
预告
TODO 《基于BasicTextField自定义TextField》