如何在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
相关产品推荐
相关产品推荐

