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

如何在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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.29 17:55:03