如何在Jetpack Compose TextField中插入并编辑图片(支持HTML输入)
Jetpack Compose 富文本 TextField 实现图文混排(含锁定视频)方案
核心思路
Compose 原生 TextField 仅支持纯文本,要实现图文混排需借助 AnnotatedString 存储富文本结构,配合自定义 VisualTransformation 渲染图片/锁定视频,同时通过状态管理处理插入、删除、HTML 解析/生成逻辑。
1. 结构化数据模型设计
先定义统一的编辑器元素模型,将文本、图片、锁定视频抽象为不同类型,方便后续处理:
sealed class EditorItem { data class Text(val content: String) : EditorItem() data class Image(val id: String, val resId: Int) : EditorItem() // 实际可替换为 Uri/网络地址 data class LockedVideo(val content: String) : EditorItem() // 存储视频标识信息 }
2. HTML 解析与逆向生成
解析 HTML 为 EditorItem 列表
使用 Jsoup 或自定义解析逻辑,提取 <image>、<video> 标签和普通文本,转换为结构化数据:
fun parseHtmlToEditorItems(html: String): List<EditorItem> { val items = mutableListOf<EditorItem>() val doc = Jsoup.parse(html) doc.body().children().forEach { element -> when (element.tagName()) { "p", "span" -> items.add(EditorItem.Text(element.text())) "image" -> { val imageId = element.attr("id") val resId = getImageResById(imageId) // 根据 id 匹配本地/网络图片资源 items.add(EditorItem.Image(imageId, resId)) } "video" -> items.add(EditorItem.LockedVideo(element.text())) } } // 处理嵌套标签(如<p>文本<image>...</p>)需递归拆分元素,此处为简化示例 return items }
将 EditorItem 转换回 HTML
编辑完成后,把结构化数据重新生成符合要求的 HTML:
fun editorItemsToHtml(items: List<EditorItem>): String { val htmlBuilder = StringBuilder() items.forEach { item -> when (item) { is EditorItem.Text -> htmlBuilder.append("<p>${item.content}</p>") is EditorItem.Image -> htmlBuilder.append("<image id=\"${item.id}\" />") is EditorItem.LockedVideo -> htmlBuilder.append("<video>${item.content}</video>") } } return htmlBuilder.toString() }
3. 富文本渲染核心:AnnotatedString + VisualTransformation
构建带注解的 AnnotatedString
用 Unicode 对象替换符 \uFFFC 作为图片/视频的占位字符,同时为占位符添加自定义注解,方便后续渲染识别:
fun editorItemsToAnnotatedString(items: List<EditorItem>): AnnotatedString { val builder = AnnotatedString.Builder() items.forEach { item -> when (item) { is EditorItem.Text -> builder.append(item.content) is EditorItem.Image -> { builder.append('\uFFFC') builder.addStringAnnotation( tag = "IMAGE", annotation = "${item.id}:${item.resId}", start = builder.length - 1, end = builder.length ) } is EditorItem.LockedVideo -> { builder.append('\uFFFC') builder.addStringAnnotation( tag = "LOCKED_VIDEO", annotation = item.content, start = builder.length - 1, end = builder.length ) } } } return builder.toAnnotatedString() }
自定义 VisualTransformation 渲染元素
拦截 AnnotatedString,将带注解的占位符替换为图片或锁定视频 UI:
class RichTextVisualTransformation( private val imageLoader: (Int) -> Painter // 图片加载逻辑,可对接 Coil/Glide ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { val allSpans = text.getStringAnnotations("IMAGE", 0, text.length) + text.getStringAnnotations("LOCKED_VIDEO", 0, text.length) val sortedSpans = allSpans.sortedBy { it.start }.reversed() // 逆序处理避免位置偏移 val transformedBuilder = AnnotatedString.Builder(text) sortedSpans.forEach { span -> val inlineContent = when (span.tag) { "IMAGE" -> { val (_, resId) = span.annotation.split(":") InlineContent(width = 100.sp, height = 100.sp) { Image( painter = imageLoader(resId.toInt()), contentDescription = "Image", modifier = Modifier.fillMaxSize() ) } } "LOCKED_VIDEO" -> InlineContent(width = 150.sp, height = 100.sp) { Box( modifier = Modifier .fillMaxSize() .background(Color.LightGray), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Default.VideoLibrary, contentDescription = "Locked Video", tint = Color.Gray ) } } else -> return@forEach } transformedBuilder.addInlineContent(span.start, span.end, inlineContent) } return TransformedText( text = transformedBuilder.toAnnotatedString(), offsetMapping = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int = offset override fun transformedToOriginal(offset: Int): Int = offset } ) } }
4. 编辑交互逻辑实现
任意位置插入图片
根据光标位置拆分文本项,插入图片元素并更新状态:
// 示例插入逻辑(绑定到按钮点击) fun insertImageAtCursor( currentItems: List<EditorItem>, cursorPos: Int, newImage: EditorItem.Image ): List<EditorItem> { val newItems = currentItems.toMutableList() val (itemIndex, charOffset) = findCursorPositionInItems(currentItems, cursorPos) if (charOffset == 0) { newItems.add(itemIndex, newImage) } else { // 光标在文本中间,拆分文本项 val textItem = currentItems[itemIndex] as EditorItem.Text val leftText = textItem.content.substring(0, charOffset) val rightText = textItem.content.substring(charOffset) newItems.removeAt(itemIndex) newItems.add(itemIndex, EditorItem.Text(leftText)) newItems.add(itemIndex + 1, newImage) newItems.add(itemIndex + 2, EditorItem.Text(rightText)) } return newItems } // 辅助方法:定位光标在 EditorItem 中的位置 fun findCursorPositionInItems(items: List<EditorItem>, cursorPos: Int): Pair<Int, Int> { var currentOffset = 0 items.forEachIndexed { index, item -> val length = when (item) { is EditorItem.Text -> item.content.length else -> 1 // 图片/视频占位符占1个字符 } if (cursorPos <= currentOffset + length) { return Pair(index, cursorPos - currentOffset) } currentOffset += length } return Pair(items.size, 0) }
删除图片与锁定视频保护
- 删除图片:监听退格键,判断光标位置是否在图片占位符上,若则删除对应元素
- 锁定视频:在
onValueChange中过滤对视频占位符的修改,拦截编辑操作
// 退格键拦截逻辑(通过 Modifier.interceptKeyBeforeSoftKeyboard 实现) Modifier.interceptKeyBeforeSoftKeyboard(Key.Backspace) { val cursorPos = textFieldValue.selection.start if (cursorPos > 0) { val imageSpan = textFieldValue.text.getStringAnnotations("IMAGE", cursorPos -1, cursorPos).firstOrNull() imageSpan?.let { editorItems = editorItems.toMutableList().apply { removeAt(findItemIndexBySpan(it)) } // 更新文本和光标位置 annotatedText = editorItemsToAnnotatedString(editorItems) textFieldValue = textFieldValue.copy( text = annotatedText, selection = TextRange(cursorPos -1) ) return@interceptKeyBeforeSoftKeyboard true } } false } // onValueChange 中保护锁定视频 onValueChange = { newVal -> val originalSpans = textFieldValue.text.getStringAnnotations("LOCKED_VIDEO", 0, textFieldValue.text.length) val isVideoModified = originalSpans.any { span -> newVal.text.getOrNull(span.start) != '\uFFFC' } if (!isVideoModified) { // 仅允许非视频区域修改 textFieldValue = newVal editorItems = textToEditorItems(newVal.text) onHtmlUpdated(editorItemsToHtml(editorItems)) } }
5. 完整 Editor 组合项
将上述逻辑整合为可复用的 Composable:
@Composable fun RichTextEditor( initialHtml: String, onHtmlUpdated: (String) -> Unit, imageLoader: (Int) -> Painter ) { var editorItems by remember { mutableStateOf(parseHtmlToEditorItems(initialHtml)) } var annotatedText by remember { mutableStateOf(editorItemsToAnnotatedString(editorItems)) } var textFieldValue by remember { mutableStateOf(TextFieldValue(annotatedText)) } TextField( value = textFieldValue, onValueChange = { /* 上述保护视频的逻辑 */ }, visualTransformation = RichTextVisualTransformation(imageLoader), modifier = Modifier .fillMaxWidth() .height(200.dp) .interceptKeyBeforeSoftKeyboard(Key.Backspace) { /* 上述删除图片逻辑 */ }, placeholder = { Text("输入内容...") } ) Button(onClick = { /* 插入图片逻辑 */ }) { Text("插入图片") } }
内容的提问来源于stack exchange,提问作者Cheng-Chong Tang
相关产品推荐
相关产品推荐

