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

如何在Jetpack Compose安卓应用中实现类推特式@提及功能

Jetpack Compose 聊天界面@提及功能的替代实现方案

问题背景

我正在开发一款基于Jetpack Compose的应用,需实现聊天界面,并在TextField中添加@提及功能:输入@后弹出建议列表,选中内容可添加到文本框;且添加的提及内容即使光标不在文本末尾,也能通过一次退格键删除。

当前采用基于索引的方案(代码如下),存在诸多边缘情况,合理性不足,寻求替代实现方案。

当前实现代码

DropdownMenuItem(
    onClick = {
        val oldText = noteMessage.text
        val mentionTextLength = mentionText?.length ?: 0
        val newText = oldText.replaceRange(
            startIndex = oldText.length - mentionTextLength,
            endIndex = oldText.length,
            replacement = "${it.getFirstName()} "
        )
        noteMessage = noteMessage.copy(
            text = newText,
            selection = TextRange(newText.length)
        )
        val start = oldText.length - mentionTextLength
        val end = oldText.length + it.getFirstName().length
        mentionedMembers.add(Pair(memberData, Pair(start, end))
    },
    modifier = Modifier.background(JcColors.SurfacePrimary)
) {
    Text(text = it.name)
}


CustomTextField(
    enabled = !viewModel.isSendingMessage.value,
    value = noteMessage,
    onValueChange = {
        val lastCharacter = it.text.lastOrNull()
        when {
            (lastCharacter == '@') -> isMentioningTeammate = true
            (lastCharacter == ' ') -> isMentioningTeammate = false
        }
        mentionText = if (isMentioningTeammate) {
            it.text.split(Regex("\\s+"))
                .lastOrNull()?.removePrefix("@")
        } else {
            null
        }
        val cursorPosition = it.selection.start
        val oldTextLength = noteMessage.text.length
        val newTextLength = it.text.length
        val removeIndexList = mutableListOf<Int>()
        var finalText = it
        if (oldTextLength != newTextLength) {
            mentionedMembers.forEachIndexed { index, (memberData, startEndPosition) ->
                if (oldTextLength < newTextLength) {
                    //something is added
                    if (cursorPosition < startEndPosition.first) {
                        val newMentionMemberData = Pair(
                            memberData,
                            Pair(
                                startEndPosition.first + 1,
                                startEndPosition.second + 1
                            )
                        )
                        mentionedMembers[index] = newMentionMemberData
                    } else if (cursorPosition > startEndPosition.first && cursorPosition < startEndPosition.second) {
                        removeIndexList.add(index)
                    }
                } else {
                    //something is deleted
                    if (cursorPosition < startEndPosition.first) {
                        val newMentionMemberData = Pair(
                            memberData,
                            Pair(
                                startEndPosition.first - 1,
                                startEndPosition.second - 1
                            )
                        )
                        mentionedMembers[index] = newMentionMemberData
                    } else if (cursorPosition > startEndPosition.first && cursorPosition < startEndPosition.second) {
                        removeIndexList.add(index)
                    }
                }
            }
//to avoid concurrent modification error
            removeIndexList.forEach { removeAtIndex ->
                mentionedMembers.removeAt(
                    removeAtIndex
                )
            }
        }
        noteMessage = finalText
        // clear mentioned list if note message is empty
        if (it.text.isEmpty()) {
            mentionedMembers.clear()
            isMentioningTeammate = false
        }
    },
    textStyle = MaterialTheme.typography.body2,
    visualTransformation = { annotatedString ->
        TransformedText(
            text = buildAnnotatedString {
                append(annotatedString)
                mentionedMembers.forEach {
                    addStyle(
                        style = SpanStyle(color = JcColors.TextNoteMentionHighlight),
                        start = it.second.first,
                        end = it.second.second
                    )
                }
            },
            offsetMapping = OffsetMapping.Identity
        )
    }
)

替代方案建议

1. 用AnnotatedString内嵌提及元数据

放弃单独维护索引列表,直接在AnnotatedString中嵌入提及的成员信息,文本修改时无需手动同步索引:

  • 选中提及用户时,构建包含自定义注解的AnnotatedString,注解中携带成员ID、显示文本等信息
  • 在onValueChange中解析新的AnnotatedString,提取所有有效提及项;处理退格时,若光标位于提及内容范围内,直接删除整个提及块

示例实现思路:

// 自定义注解键
val MEMBER_MENTION_KEY = "member_mention"

// 选中用户时构建带注解的文本
fun insertMention(currentText: AnnotatedString, member: MemberData): AnnotatedString {
    val mentionString = "${member.getFirstName()} "
    return buildAnnotatedString {
        append(currentText)
        // 替换当前输入的@xxx部分
        val lastAtIndex = currentText.text.lastIndexOf('@')
        if (lastAtIndex != -1) {
            delete(lastAtIndex, currentText.length)
        }
        // 添加提及文本并嵌入注解
        pushStringAnnotation(
            tag = MEMBER_MENTION_KEY,
            annotation = member.id,
            start = length,
            end = length + mentionString.length
        )
        addStyle(
            style = SpanStyle(color = JcColors.TextNoteMentionHighlight),
            start = length,
            end = length + mentionString.length
        )
        append(mentionString)
    }
}

// 处理退格逻辑
fun handleBackspace(text: AnnotatedString, selection: TextRange): AnnotatedString {
    val start = selection.start
    // 检查光标位置是否在某个提及块内
    val annotations = text.getStringAnnotations(MEMBER_MENTION_KEY, start, start)
    if (annotations.isNotEmpty()) {
        // 删除整个提及块
        return text.removeRange(annotations.first().start, annotations.first().end)
    }
    // 正常退格
    return if (start > 0) text.removeRange(start - 1, start) else text
}

2. 封装提及为独立原子单元

将每个提及内容视为不可分割的原子单元,用自定义数据结构管理整个文本内容:

sealed class ChatTextElement {
    data class PlainText(val content: String) : ChatTextElement()
    data class Mention(val member: MemberData) : ChatTextElement()
}
  • 维护List<ChatTextElement>存储聊天内容,而非原始字符串
  • 输入@触发建议列表,选中后向列表添加Mention元素
  • 渲染时将List<ChatTextElement>转换为AnnotatedString处理样式
  • 退格时直接删除光标所在的整个元素(PlainText或Mention),彻底避免索引同步问题

3. 分离存储与显示(占位符方案)

若坚持用字符串存储,可通过visualTransformation将特殊占位符转换为显示文本:

  • 选中用户时,在文本中插入特殊标记(如[mention:${member.id}]),而非直接插入用户名
  • visualTransformation将标记转换为带样式的用户名显示
  • 退格时检测光标是否在标记范围内,一次删除整个标记,避免因用户名长度变化导致的索引偏移

内容的提问来源于stack exchange,提问作者Pravesh Singh

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.11 15:07:35