从 EditText 转向 Compose TextField,你会发现有些效果变得难以实现了,比如maxLength
本文旨在向你展示如何在 BasicTextField 和 BasicTextField2上实现maxLength效果,包括对粘贴、光标位置等细节逻辑的处理,并最终封装出一个更易用更通用的CommonTextField
文章比较啰嗦,如果你想直接看最终方案,拉到最终章即可
但在这之前,我想先阐述一下:
什么是BasicTextField 和 BasicTextField2
material包中的TextField
如果你引入了 androidx.compose.material 包,那么你可以使用其中封装好的TextField,它实现了一些 符合 Material Design 的额外 UI 效果,比如 Label、Icon 等
详细的使用文档很多,比如你可以参看:降 Compose 十八掌之『双龙取水』| Text Edit - 掘金
如果你不想要这么多花里胡哨的效果,只需要个纯净的 TextField来自定义成想要的样子,那么就可以使用
androidx.compose.foundation包中的BasicTextField,实际上 material 包中的TextField也是这么做的,内部实现就是BasicTextField
所以你可以看到在官方文档中,如果你想依赖 Compose,那么可以按需选择更丰富的material包,或者依赖基础一些的foundation,亦或者硬核到全部自己再封装一遍,只依赖最基础的ui包
这里我还是推荐大家依赖material3包,虽然对于 TextField可能用不上,但其它组件总是有能用上的
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2024.06.00')
implementation composeBom
androidTestImplementation composeBom
// Choose one of the following:
// Material Design 3
implementation 'androidx.compose.material3:material3'
// or Material Design 2
implementation 'androidx.compose.material:material'
// or skip Material Design and build directly on top of foundational components
implementation 'androidx.compose.foundation:foundation'
// or only import the main APIs for the underlying toolkit systems,
// such as input and measurement/layout
implementation 'androidx.compose.ui:ui'
...
}
BasicTextField2和BasicTextField的区别
作为新兴框架,Compose处于高速更新迭代的过程中
对于BasicTextField,在foundation包的1.6.0版本上,开发者团队对其进行了较大的变更,更新了一系列 API,新增了BasicTextField2。具体内容可以看这篇文章:探索 Compose 新输入框:BasicTextField2 - 掘金
BasicTextField2并不是永久命名,而是过渡性的,在经过版本验证后,会将其转正更名为BasicTextField,而原先BasicTextField中的方法则会被标记为已废弃
实际上在foundation包的1.7.0-alpha04版本上,它就已经完成转正了:
PS. 截止本文时间,最新的material包版本androidx.compose.material3:material3:1.3.0-beta05中依赖的foundation版本是 1.7.0-beta02,已经包括了上述的变动
所以如果你依赖最新些的版本的话,其实你是找不到BasicTextField2这个类的,它已经转正变成BasicTextField中的一部分了
但为了更好的区分,本文后面还是统一称呼其为 BasicTextField2
在BasicTextField上实现maxLength效果
首先创建一个最简单的BasicTextField实现:
(decorationBox中的innerTextField可以理解为输入框本体,是没有任何背景和其它元素存在的,如果你想给他加背景以及加其它元素,那么就用其它 Compose 组件包围它就行了)
var textValue by remember { mutableStateOf("") }
BasicTextField(
modifier = Modifier
.align(Alignment.Center)
.height(50.dp),
value = textValue,
onValueChange = { textValue = it },
textStyle = TextStyle(color = Color.Black, fontSize = 16.sp),
decorationBox = @Composable { innerTextField ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White, RoundedCornerShape(5.dp)),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.weight(1f)
.padding(horizontal = 20.dp)
) {
innerTextField()
}
}
})
基于String类型的Value处理
上面用到的BasicTextField重载方法中, value 对应的类型是 String,参看源码并没有maxLength相关的参数和实现,所以只能靠自己来实现了
最先能想到的方式就是对 value 进行 subString操作:
var textValue by remember { mutableStateOf("") }
val maxLength = 5
BasicTextField(
value = textValue,
onValueChange = { text ->
textValue = if (text.length > maxLength) {
text.substring( 0 , maxLength)
} else {
text
}
},
...)
验证效果:当字符数达到 5 个时,确实不再能继续输入了。但当把光标移动到前面时,又能继续输入并且把后面的字符顶掉了。这应该不是想要的效果,理想效果是无论如何都不能再输入了,所以继续添加判断——当前字符已经达到最大值时,不再更新:
onValueChange = { text ->
if (textValue.length < maxLength) {
textValue = if (text.length > maxLength) {
text.substring(0, maxLength)
} else {
text
}
}
}
这下新的键入不会生效了,但更诡异的事情出现了:光标在跟随键入而变动
显然,BasicTextField内部对键入和光标的处理有自己的一套基准逻辑,我们单纯的对 String类型的Value做修改,应该是无法完全实现maxLength的效果了
基于TextFieldValue类型的Value处理
好在BasicTextField给我们提供了包含光标信息的数据类TextFieldValue,改造一下:
var textFieldValue by remember { mutableStateOf(TextFieldValue()) }
BasicTextField(
modifier = Modifier
.align(Alignment.Center)
.height(50.dp),
value = textFieldValue,
onValueChange = { value ->
textFieldValue = filterMaxLength(
new = value,
old = textFieldValue,
maxLength = 5
)
},
...
)
核心的 filterMaxLength 完整实现代码:
此处参考了文章:Compose 没有 inputType 怎么过滤(限制)输入内容?这题我会! - 掘金,但在其基础上做了一些优化,包括对粘贴逻辑的处理等
private fun filterMaxLength(
new: TextFieldValue,
old: TextFieldValue,
maxLength: Int
): TextFieldValue {
// 错误的长度,不处理直接返回
if (maxLength < 0) return new
// 总计输入内容没有超出长度限制
if (new.text.length <= maxLength) return new
// 输入内容超出了长度限制, 这里要分两种情况:
// 1. 直接输入的,则返回原数据即可
// 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入
val oldStart = old.selection.start
val oldEnd = old.selection.end
// 计算实际的旧字符数,以总字符数-被光标框选的长度(因为这部分会被替换)
val oldCount = (old.text.length - (oldEnd - oldStart))
val newCount = new.text.length
// 计算这次新增了几个字符
val inputCharCount = newCount - oldCount
try {
// 同时粘贴了多个字符内容
if (inputCharCount > 1) {
val allowCount = maxLength - oldCount
// 允许再输入字符已经为空,则直接返回原数据
if (allowCount <= 0) return old
// 截取应该新增的字符部分
val newChar = new.text.substring(oldStart, oldStart + allowCount)
// 从光标起始位置开始插入新增字符(前后补全旧字符)
val newText = buildString {
append(old.text, 0, oldStart)
append(newChar)
append(old.text, oldEnd, old.text.length)
}
return old.copy(
text = newText,
selection = TextRange(oldStart + newChar.length)
)
} else {
// 正常输入
return if (new.selection.collapsed) {
// 如果当前不是选中状态,则使用上次输入的光标位置,如果使用本次的位置,光标位置会 +1
old
} else {
// 如果当前是选中状态,则使用当前的光标位置
old.copy(selection = new.selection)
}
}
} catch (e: Exception) {
logError(e)
return old
}
}
最终效果:
-
超限后在任何位置都无法输入,光标不会错乱
-
在中间粘贴长文案,原先的尾部字符仍然保留
-
光标勾选部分字符进行替换,光标外的字符仍然保留
在BasicTextField2上实现maxLength效果
虽然上面已经实现了字符限制,但总感觉怪怪的:
这应该是一个文本输入框的基础能力,为什么要我自己来实现?
后面如果想继续扩展,比如仅限输入数字、邮箱等逻辑,难道又要自己吭哧吭哧写?还做不做业务了,写出 bug 怎么办?
好在官方也是注意到了这个问题,在BasicTextField2上新增了InputTransformation用于处理输入过滤
1.7.0-alpha04版本之后已经更名为BasicTextField,作为重载方法之一了
目前官方提供了几个实现,不多,但拓展指日可待。包括:
AllCapsTransformation:转化大写PasswordInputTransformation:转化密码比如变成***MaxLengthFilter:正主来了,但是官方备注了This is a very naive implementation for now, not intended to be production-ready.,说明其是一个非常初始的版本,还不适合用在生产环境,也就是逻辑还没写完
另外还支持FilterChain可以对过滤进行链式调用, 执行诸如【先转换成 xx 再过滤 xx】的逻辑
看下官方的MaxLengthFilter代码:
// This is a very naive implementation for now, not intended to be production-ready.
private data class MaxLengthFilter(
private val maxLength: Int
) : InputTransformation {
init {
require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" }
}
override fun SemanticsPropertyReceiver.applySemantics() {
maxTextLength = maxLength
}
override fun TextFieldBuffer.transformInput() {
if (length > maxLength) {
revertAllChanges()
}
}
override fun toString(): String {
return "InputTransformation.maxLength($maxLength)"
}
}
处理简陋的过分,就是在输入时,如果结果超限,那么直接放弃输入,回退到之前的文案。对于粘贴操作是一概不处理的,没有截断,没有替换
自定义MaxLength的InputTransformation
好在我们可以在这套框架上自己扩展,把BasicTextField中对TextFieldValue的处理搬过来,核心实现逻辑都是一样的:
data class InputFilterMaxLength(
private val maxLength: Int
) : InputTransformation {
init {
require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" }
}
override fun SemanticsPropertyReceiver.applySemantics() {
maxTextLength = maxLength
}
override fun TextFieldBuffer.transformInput() {
// 总计输入内容没有超出长度限制
if (length <= maxLength) return
// 输入内容超出了长度限制, 这里要分两种情况:
// 1. 直接输入的,则返回原数据即可
// 2. 粘贴后会导致长度超出,此时可能还可以输入部分字符,所以需要判断后截断输入
val oldStart = originalSelection.start
val oldEnd = originalSelection.end
// 计算实际的旧字符数,以总字符数-被光标框选的长度(因为这部分会被替换)
val oldCount = (originalText.length - (oldEnd - oldStart))
val newCount = length
// 计算这次新增了几个字符
val inputCharCount = newCount - oldCount
val allowCount = maxLength - oldCount
// 允许再输入字符已经为空,则直接返回原数据
if (allowCount <= 0) {
revertAllChanges()
return
}
try {
// 同时粘贴了多个字符内容
if (inputCharCount > 1) {
// 截取应该新增的字符部分
val newChar = asCharSequence().substring(oldStart, oldStart + allowCount)
// 从光标起始位置开始插入新增字符(前后补全旧字符)
val newText = buildString {
append(originalText, 0, oldStart)
append(newChar)
append(originalText, oldEnd, originalText.length)
}
replace(0, length, newText)
selection = TextRange(oldStart + newChar.length)
}
} catch (e: Exception) {
logError(e)
revertAllChanges()
}
}
override fun toString(): String {
return "InputTransformation.maxLength($maxLength)"
}
}
调用方式:
新方法中对文本的赋值和使用,已经从
value改为了state,本文不多阐述,感兴趣详见:探索 Compose 新输入框:BasicTextField2 - 掘金
val textFieldState = rememberTextFieldState()
CommonTextField(
state = textFieldState,
inputTransformation = InputFilterMaxLength(5)
)
完整封装
针对不同的业务有不同的封装,这里仅作为参考,内部实现包括:
- 集成
hint和一键清除效果(有开关) - 添加
maxLength参数更方便使用 - 默认实现业务通用的
color、size等
完整代码:
@Composable
fun CommonTextField(
modifier: Modifier = Modifier,
state: TextFieldState,
fontSize: TextUnit = 16.sp,
fontColor: Color = Color(0xCCFFFFFF),
textStyle: TextStyle = TextStyle.Default,
maxLength: Int? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine,
hint: String? = null,
hintColor: Color = Color(0x4DFFFFFF),
contentAlignment: Alignment.Vertical = Alignment.CenterVertically,
backgroundColor: Color = Color(0x33FFFFFF),
backgroundShape: Shape = RoundedCornerShape(4.dp),
horizontalPadding: Dp = 20.dp,
enabled: Boolean = true,
enabledClearText: Boolean = false,
readOnly: Boolean = false,
leading: (@Composable RowScope.() -> Unit)? = null,
trailing: (@Composable RowScope.() -> Unit)? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onKeyboardAction: KeyboardActionHandler? = null,
onTextLayout: (Density.(getResult: () -> TextLayoutResult?) -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
cursorBrush: Brush = SolidColor(Color(0xFFB9FB65)),
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
decorator: TextFieldDecorator? = null,
scrollState: ScrollState = rememberScrollState(),
) {
val combinedModifier = modifier.then(Modifier.height(50.dp))
val inputTrans = maxLength?.let { max ->
InputFilterMaxLength(max).let { maxLengthFilter ->
inputTransformation?.then(maxLengthFilter) ?: maxLengthFilter
}
} ?: inputTransformation
BasicTextField(
state = state,
modifier = combinedModifier,
enabled = enabled,
readOnly = readOnly,
inputTransformation = inputTrans,
textStyle = textStyle.copy(color = fontColor, fontSize = fontSize),
keyboardOptions = keyboardOptions,
onKeyboardAction = onKeyboardAction,
lineLimits = lineLimits,
onTextLayout = onTextLayout,
interactionSource = interactionSource,
cursorBrush = cursorBrush,
outputTransformation = outputTransformation,
scrollState = scrollState,
decorator = decorator ?: TextFieldDecorator { innerTextField ->
CommonTextFieldContent(
innerTextField = innerTextField,
valueIsEmpty = { state.text.isEmpty() },
fontSize = fontSize,
textStyle = textStyle,
maxLines = if (lineLimits is MultiLine) lineLimits.maxHeightInLines else 1,
hint = hint,
hintColor = hintColor,
contentAlignment = contentAlignment,
backgroundColor = backgroundColor,
backgroundShape = backgroundShape,
horizontalPadding = horizontalPadding,
enabledClearText = enabledClearText,
onClearValue = { state.clearText() },
leading = leading,
trailing = trailing,
)
}
)
}
//放置背景等布局,并放置基础输入框
@Composable
private inline fun CommonTextFieldContent(
innerTextField: @Composable () -> Unit,
valueIsEmpty: () -> Boolean,
fontSize: TextUnit,
textStyle: TextStyle,
maxLines: Int,
hint: String?,
hintColor: Color,
contentAlignment: Alignment.Vertical,
backgroundColor: Color,
backgroundShape: Shape,
horizontalPadding: Dp,
enabledClearText: Boolean,
crossinline onClearValue: () -> Unit,
noinline leading: (@Composable RowScope.() -> Unit)?,
noinline trailing: (@Composable RowScope.() -> Unit)?,
) {
Row(
Modifier
.fillMaxSize()
.background(backgroundColor, backgroundShape)
.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically
) {
if (leading != null) {
leading()
HorizontalSpace(horizontalPadding)
}
Box(
Modifier
.weight(1f)
.align(contentAlignment)
.let {
if (contentAlignment != Alignment.CenterVertically) {
it.padding(vertical = horizontalPadding / 2)
} else {
it
}
}
) {
if (valueIsEmpty() && !hint.isNullOrEmpty()) {
Text(
text = hint,
style = textStyle.copy(color = hintColor, fontSize = fontSize),
maxLines = maxLines,
)
}
innerTextField()
}
// 如果启用尾部的清空 Text 按钮,则 trailing 会失效
if (enabledClearText) {
AnimatedVisibility(visible = !valueIsEmpty.invoke()) {
Row {
HorizontalSpace(horizontalPadding)
IconFontText(
iconRes = R.string.ic_clear_input_solid,
iconSize = 18.dp,
iconColor = Colors.TextWhiteSecondary,
size = 18.dp
) {
onClearValue.invoke()
}
}
}
} else {
if (trailing != null) {
HorizontalSpace(horizontalPadding)
trailing()
}
}
}
}