You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose实现带自定义表情的TextField问题求助

解决Jetpack Compose TextField表情Token显示与交互问题

核心思路

要解决光标错乱、退格异常的问题,核心是让底层文本长度与显示的表情数量一致:用单个特殊占位字符(如Unicode私用区字符)替换{:d_1:}这类多字符token,再通过VisualTransformation将占位符渲染为表情图片。同时处理输入时的token自动替换、退格时的整表情删除逻辑,确保光标行为符合预期。

具体实现步骤

1. Token解析与占位符替换工具类

用于识别{:xxx:}格式的token,将其替换为单个占位字符,并记录占位符与原始token的映射关系:

object TokenProcessor {
    // 匹配{:xxx:}格式的正则
    private val tokenRegex = Regex("\\{:(\\w+):\\}")
    // 用Unicode私用区字符作为占位符,避免与普通输入冲突
    private const val PLACEHOLDER = '\uE000'

    // 将原始文本中的token替换为占位符,返回处理后的文本+占位符到token的映射
    fun replaceTokensWithPlaceholders(text: String): Pair<String, Map<Int, String>> {
        val placeholderMap = mutableMapOf<Int, String>()
        var processedText = text
        var offset = 0

        tokenRegex.findAll(text).forEach { match ->
            val token = match.value
            val tokenStart = match.range.start - offset
            val tokenEnd = match.range.endInclusive - offset + 1

            // 替换token为单个占位符
            processedText = processedText.replaceRange(tokenStart, tokenEnd, PLACEHOLDER.toString())
            placeholderMap[tokenStart] = token
            // 更新偏移量(token长度-1是替换后减少的字符数)
            offset += token.length - 1
        }
        return processedText to placeholderMap
    }

    // 检查文本中是否存在未处理的完整token
    fun hasUnprocessedToken(text: String): Boolean = tokenRegex.containsMatchIn(text)

    // 根据占位符位置获取对应的原始token
    fun getTokenForPos(pos: Int, placeholderMap: Map<Int, String>): String? = placeholderMap[pos]
}

2. 自定义VisualTransformation渲染表情

将占位符转换为表情图片,同时实现文本位置映射,保证光标位置正确:

class EmojiVisualTransformation(
    private val emojiPainterProvider: (String) -> Painter?
) : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val originalText = text.text
        val (processedText, placeholderMap) = TokenProcessor.replaceTokensWithPlaceholders(originalText)

        val annotatedBuilder = AnnotatedString.Builder()
        var currentIndex = 0

        processedText.forEachIndexed { index, char ->
            if (char == TokenProcessor.PLACEHOLDER) {
                // 添加占位符前的普通文本
                if (index > currentIndex) {
                    annotatedBuilder.append(processedText.substring(currentIndex, index))
                }
                // 获取对应token并渲染表情
                val token = placeholderMap[index] ?: return@forEachIndexed
                val painter = emojiPainterProvider(token) ?: return@forEachIndexed

                val inlineContent = InlineTextContent(
                    Placeholder(
                        width = 24.sp.toPx(),
                        height = 24.sp.toPx(),
                        placeholderVerticalAlign = PlaceholderVerticalAlign.Center
                    )
                ) {
                    Image(
                        painter = painter,
                        contentDescription = token,
                        modifier = Modifier.fillMaxSize()
                    )
                }
                annotatedBuilder.appendInlineContent(id = token, text = char.toString())
                annotatedBuilder.addInlineContent(token, inlineContent)
                currentIndex = index + 1
            }
        }
        // 添加剩余的普通文本
        if (currentIndex < processedText.length) {
            annotatedBuilder.append(processedText.substring(currentIndex))
        }

        // 实现原始文本与转换后文本的位置映射,修正光标偏移
        val offsetMapping = object : OffsetMapping {
            override fun originalToTransformed(offset: Int): Int {
                var transformedOffset = offset
                placeholderMap.keys.sortedDescending().forEach { pos ->
                    val tokenLength = placeholderMap[pos]?.length ?: 0
                    if (offset > pos) transformedOffset -= (tokenLength - 1)
                }
                return transformedOffset.coerceIn(0, processedText.length)
            }

            override fun transformedToOriginal(offset: Int): Int {
                var originalOffset = offset
                placeholderMap.keys.sorted().forEach { pos ->
                    val tokenLength = placeholderMap[pos]?.length ?: 0
                    if (offset > pos) originalOffset += (tokenLength - 1)
                }
                return originalOffset.coerceIn(0, originalText.length)
            }
        }

        return TransformedText(annotatedBuilder.toAnnotatedString(), offsetMapping)
    }
}

3. TextField集成与交互处理

管理TextField状态,处理输入时的token自动替换、退格时的整表情删除:

@Composable
fun EmojiTextField() {
    // 表情token与资源的映射
    val emojiMap = remember {
        mapOf(
            "{:d_1:}" to painterResource(R.drawable.emoji_d1),
            "{:smile:}" to painterResource(R.drawable.emoji_smile)
        )
    }

    var textFieldValue by remember { mutableStateOf(TextFieldValue("")) }

    // 输入内容变化时自动替换token为占位符
    val onValueChange = { newValue: TextFieldValue ->
        var updatedText = newValue.text
        var updatedSelection = newValue.selection

        if (TokenProcessor.hasUnprocessedToken(updatedText)) {
            val (processedText, _) = TokenProcessor.replaceTokensWithPlaceholders(updatedText)
            // 计算光标偏移:原光标位置减去token替换减少的字符数
            val lengthDiff = updatedText.length - processedText.length
            val newSelStart = (newValue.selection.start - lengthDiff).coerceAtLeast(0)
            val newSelEnd = (newValue.selection.end - lengthDiff).coerceAtLeast(0)
            updatedText = processedText
            updatedSelection = TextRange(newSelStart, newSelEnd)
        }

        textFieldValue = newValue.copy(text = updatedText, selection = updatedSelection)
    }

    // 拦截退格键,删除整个表情占位符
    val onKeyEvent = { event: KeyEvent ->
        if (event.type == KeyEventType.KeyDown && event.key == Key.Backspace) {
            val cursorPos = textFieldValue.selection.start
            if (cursorPos > 0 && textFieldValue.text[cursorPos - 1] == TokenProcessor.PLACEHOLDER) {
                // 删除占位符并调整光标位置
                val newText = textFieldValue.text.removeRange(cursorPos - 1, cursorPos)
                textFieldValue = textFieldValue.copy(
                    text = newText,
                    selection = TextRange(cursorPos - 1, cursorPos - 1)
                )
                return@onKeyEvent true // 消费事件,覆盖默认退格逻辑
            }
        }
        false // 不处理其他按键
    }

    BasicTextField(
        value = textFieldValue,
        onValueChange = onValueChange,
        onKeyEvent = onKeyEvent,
        visualTransformation = EmojiVisualTransformation { token -> emojiMap[token] },
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
            .background(Color.LightGray, RoundedCornerShape(8.dp))
            .padding(12.dp),
        textStyle = TextStyle(fontSize = 16.sp)
    )
}

关键细节说明

  • 占位符选择:使用Unicode私用区字符(U+E000~U+F8FF),不会被用户正常输入,避免冲突。
  • OffsetMapping:必须正确实现原始文本与转换后文本的位置映射,否则会出现光标跳变、选中范围异常的问题。
  • 退格拦截:通过onKeyEvent拦截退格键,判断光标前是否为占位符,若是则直接删除整个占位符,而非单个字符。
  • 实时替换:输入时检测完整token并自动替换为占位符,同时调整光标到表情后的正确位置。

内容的提问来源于stack exchange,提问作者dev.farmer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.28 02:59:53