Compose 在 BasicTextField 和 BasicTextField2 上实现 maxLength 效果

1,140 阅读8分钟

EditText 转向 Compose TextField,你会发现有些效果变得难以实现了,比如maxLength

本文旨在向你展示如何在 BasicTextFieldBasicTextField2上实现maxLength效果,包括对粘贴、光标位置等细节逻辑的处理,并最终封装出一个更易用更通用的CommonTextField

文章比较啰嗦,如果你想直接看最终方案,拉到最终章即可

但在这之前,我想先阐述一下:

什么是BasicTextFieldBasicTextField2

material包中的TextField

如果你引入了 androidx.compose.material 包,那么你可以使用其中封装好的TextField,它实现了一些 符合 Material Design 的额外 UI 效果,比如 LabelIcon

详细的使用文档很多,比如你可以参看:降 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'

    ...
}

BasicTextField2BasicTextField的区别

作为新兴框架,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
    }
}

最终效果:

  1. 超限后在任何位置都无法输入,光标不会错乱

  1. 在中间粘贴长文案,原先的尾部字符仍然保留

  1. 光标勾选部分字符进行替换,光标外的字符仍然保留

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)"
    }
}

处理简陋的过分,就是在输入时,如果结果超限,那么直接放弃输入,回退到之前的文案。对于粘贴操作是一概不处理的,没有截断,没有替换

自定义MaxLengthInputTransformation

好在我们可以在这套框架上自己扩展,把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 参数更方便使用
  • 默认实现业务通用的colorsize

完整代码:

@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()
            }
        }
    }
}