Android Compose:如何在文本视图中使用HTML标签

59

我从外部源获得了一个包含以下格式HTML标签的字符串:

"Hello, I am <b> bold</b> text"

在使用Compose之前,我会在HTML字符串开头使用CDATA,并使用Html.fromHtml()将其转换为Spanned对象,然后将其传递给TextView。这样TextView中的单词"bold"将会以加粗字体呈现。

我已经尝试在Compose中复制此过程,但是我找不到确切的步骤来使我成功地实现它。

任何建议都将不胜感激。


3
你需要将HTML转换为“AnnotatedString”。据我所知,目前没有HTML->“AnnotatedString”转换器或“Spanned”->“AnnotatedString”转换器。有一些Markdown->“AnnotatedString”转换器,但在这种特定情况下不太可能有所帮助。你可能需要自己创建一个合适的转换器。 - CommonsWare
@CommonsWare,那不是我希望得到的答案,但感谢你如此迅速地回复。这将为我节省大量徒劳的搜索时间。谢谢。 - William
这里有一个解决方案:https://dev59.com/HVEG5IYBdhLWcg3wO3dP#69902377 - Johann
我认为这是Android端的相关功能请求,请问您是否受到影响并能否投票支持一下?https://issuetracker.google.com/issues/174348612 - Alejandra
13个回答

44

目前还没有官方的 Composable 可以完成这个任务。现在我正在使用一个带有 TextView 的 AndroidView。虽然不是最好的解决方案,但它很简单并且可以解决问题。

@Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
    AndroidView(
            modifier = modifier,
            factory = { context -> TextView(context) },
            update = { it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) }
    )
}
如果HTML中有标签,您需要设置TextView属性movementMethod = LinkMovementMethod.getInstance()使链接可点击。

41
我正在使用这个小助手函数,它将一些Span(Spanned)转换为SpanStyle(AnnotatedString/Compose)替代品。
    /**
     * Converts a [Spanned] into an [AnnotatedString] trying to keep as much formatting as possible.
     *
     * Currently supports `bold`, `italic`, `underline` and `color`.
     */
    fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
        val spanned = this@toAnnotatedString
        append(spanned.toString())
        getSpans(0, spanned.length, Any::class.java).forEach { span ->
            val start = getSpanStart(span)
            val end = getSpanEnd(span)
            when (span) {
                is StyleSpan -> when (span.style) {
                    Typeface.BOLD -> addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
                    Typeface.ITALIC -> addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
                    Typeface.BOLD_ITALIC -> addStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic), start, end)
                }
                is UnderlineSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
                is ForegroundColorSpan -> addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
            }
        }
    }

这是一个使用它的示例。
val spannableString = SpannableStringBuilder("<b>Hello</b> <i>World</i>").toString()
val spanned = HtmlCompat.fromHtml(spannableString, HtmlCompat.FROM_HTML_MODE_COMPACT)

Text(text = spanned.toAnnotatedString())

1
请提供一个实现的示例。 - Salam El-Banna
2
URLSpan怎么样? - user2836943
简单而有效的答案 - Rahul Singh Chandrabhan
如果有人在寻找删除线的方法,请注意以下内容:将StrikethroughSpan应用样式addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)。 - Gv Ravi
它的效果很好,直到你尝试将文本和fontWeight样式一起传递时。 - Eugen Martynov
显示剩余2条评论

17

由于我正在使用带有Android Jetpack Compose和JetBrains Compose for Desktop的Kotlin Multiplatform项目,因此我没有回退到Android的TextView的选项。

因此,我受到了turbohenoch的答案的启发,并尽力扩展它以能够解释多个(可能嵌套的)HTML格式标记。

代码肯定可以改进,而且对HTML错误不太健壮,但是我确实测试了包含<u><b>标记的文本,并且至少对此有效。

以下是代码:

/**
 * The tags to interpret. Add tags here and in [tagToStyle].
 */
private val tags = linkedMapOf(
    "<b>" to "</b>",
    "<i>" to "</i>",
    "<u>" to "</u>"
)

/**
 * The main entry point. Call this on a String and use the result in a Text.
 */
fun String.parseHtml(): AnnotatedString {
    val newlineReplace = this.replace("<br>", "\n")

    return buildAnnotatedString {
        recurse(newlineReplace, this)
    }
}

/**
 * Recurses through the given HTML String to convert it to an AnnotatedString.
 * 
 * @param string the String to examine.
 * @param to the AnnotatedString to append to.
 */
private fun recurse(string: String, to: AnnotatedString.Builder) {
    //Find the opening tag that the given String starts with, if any.
    val startTag = tags.keys.find { string.startsWith(it) }
    
    //Find the closing tag that the given String starts with, if any.
    val endTag = tags.values.find { string.startsWith(it) }

    when {
        //If the String starts with a closing tag, then pop the latest-applied
        //SpanStyle and continue recursing.
        tags.any { string.startsWith(it.value) } -> {
            to.pop()
            recurse(string.removeRange(0, endTag!!.length), to)
        }
        //If the String starts with an opening tag, apply the appropriate
        //SpanStyle and continue recursing.
        tags.any { string.startsWith(it.key) } -> {
            to.pushStyle(tagToStyle(startTag!!))
            recurse(string.removeRange(0, startTag.length), to)
        }
        //If the String doesn't start with an opening or closing tag, but does contain either,
        //find the lowest index (that isn't -1/not found) for either an opening or closing tag.
        //Append the text normally up until that lowest index, and then recurse starting from that index.
        tags.any { string.contains(it.key) || string.contains(it.value) } -> {
            val firstStart = tags.keys.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
            val firstEnd = tags.values.map { string.indexOf(it) }.filterNot { it == -1 }.minOrNull() ?: -1
            val first = when {
                firstStart == -1 -> firstEnd
                firstEnd == -1 -> firstStart
                else -> min(firstStart, firstEnd)
            }

            to.append(string.substring(0, first))

            recurse(string.removeRange(0, first), to)
        }
        //There weren't any supported tags found in the text. Just append it all normally.
        else -> {
            to.append(string)
        }
    }
}

/**
 * Get a [SpanStyle] for a given (opening) tag.
 * Add your own tag styling here by adding its opening tag to
 * the when clause and then instantiating the appropriate [SpanStyle].
 * 
 * @return a [SpanStyle] for the given tag.
 */
private fun tagToStyle(tag: String): SpanStyle {
    return when (tag) {
        "<b>" -> {
            SpanStyle(fontWeight = FontWeight.Bold)
        }
        "<i>" -> {
            SpanStyle(fontStyle = FontStyle.Italic)
        }
        "<u>" -> {
            SpanStyle(textDecoration = TextDecoration.Underline)
        }
        //This should only throw if you add a tag to the [tags] Map and forget to add it 
        //to this function.
        else -> throw IllegalArgumentException("Tag $tag is not valid.")
    }
}

我尽力清晰地注释了代码,以下是快速解释。 tags变量是要跟踪的标签映射,键为起始标签,值为对应的结束标签。在tagToStyle()函数中也需要处理这些内容,以便代码可以为每个标签获取适当的SpanStyle。
然后,它递归扫描输入字符串,查找跟踪的起始和结束标记。
如果给定的字符串以结束标记开头,则它将弹出最近应用的SpanStyle(从那时起删除所有附加的文本),并在删除该标记的情况下调用该字符串的递归函数。
如果给定的字符串以起始标记开头,则它将推送相应的SpanStyle(使用tagToStyle()),然后在删除该标记的情况下调用该字符串的递归函数。
如果给定的字符串既不以结束标记也不以起始标记开头,但包含至少一个标记,则它将查找任何跟踪标记的第一个出现位置(起始或结束),通常附加给定字符串中的所有文本,直到该索引,然后从第一个发现的跟踪标记的索引开始调用递归函数。
如果给定的字符串没有任何标记,则它将正常附加,而不添加或删除任何样式。
由于我正在使用这个应用程序进行积极开发,因此我可能会根据需要继续更新它。假设没有什么重大变化,最新版本应该可以在其GitHub存储库上获得。

10

对于简单的使用情况,您可以像这样做:

private fun String.parseBold(): AnnotatedString {
    val parts = this.split("<b>", "</b>")
    return buildAnnotatedString {
        var bold = false
        for (part in parts) {
            if (bold) {
                withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
                    append(part)
                }
            } else {
                append(part)
            }
            bold = !bold
        }
    }
}

然后在@Composable中使用这个带注释的字符串

Text(text = "Hello, I am <b> bold</b> text".parseBold())

当然,如果您尝试支持更多标签,则会变得更加棘手。
如果您正在使用字符串资源,请使用类似以下的添加标签 -
<string name="intro"><![CDATA[Hello, I am <b> bold</b> text]]></string>

感谢您节省了我的时间,这也适用于多平台项目。 - undefined

10
你可以尝试使用compose-html,这是一个为Jetpack Compose文本提供HTML支持的Android库。
由于可组合的Text布局不提供任何HTML支持,该库通过暴露可组合的HtmlText布局来填补这一空白。它是基于Text布局和Span/Spannable Android类构建的(实现基于@Sven的答案)。其API如下所示:
HtmlText(
    text = htmlString,
    linkClicked = { link ->
        Log.d("linkClicked", link)
    }
)

这些是所有可用的参数,允许您更改默认行为:

fun HtmlText(
    text: String,
    modifier: Modifier = Modifier,
    style: TextStyle = TextStyle.Default,
    softWrap: Boolean = true,
    overflow: TextOverflow = TextOverflow.Clip,
    maxLines: Int = Int.MAX_VALUE,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    linkClicked: (String) -> Unit = {},
    fontSize: TextUnit = 14.sp,
    flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT,
    URLSpanStyle: SpanStyle = SpanStyle(
    color = linkTextColor(),
    textDecoration = TextDecoration.Underline
    )
)

HtmlText支持几乎与android.widget.TextView相同数量的HTML标签,但不支持<img>标签和<ul>标签,后者部分支持,因为HtmlText可以正确渲染列表元素,但不会添加项目符号(•)。


2
这个答案应该更靠近顶部。 - acmpo6ou
1
@acmpo6ou 本地解决方案应该排在前面,而不是第三方库,因为有时候无法将其集成到生产环境中。 - undefined

6

这是我的解决方案,还支持超链接:

@Composable
fun HtmlText(
    html: String,
    modifier: Modifier = Modifier,
    style: TextStyle = TextStyle.Default,
    hyperlinkStyle: TextStyle = TextStyle.Default,
    softWrap: Boolean = true,
    overflow: TextOverflow = TextOverflow.Clip,
    maxLines: Int = Int.MAX_VALUE,
    onTextLayout: (TextLayoutResult) -> Unit = {},
    onHyperlinkClick: (uri: String) -> Unit = {}
) {
    val spanned = remember(html) {
        HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY, null, null)
    }

    val annotatedText = remember(spanned, hyperlinkStyle) {
        buildAnnotatedString {
            append(spanned.toString())

            spanned.getSpans(0, spanned.length, Any::class.java).forEach { span ->
                val startIndex = spanned.getSpanStart(span)
                val endIndex = spanned.getSpanEnd(span)

                when (span) {
                    is StyleSpan -> {
                        span.toSpanStyle()?.let {
                            addStyle(style = it, start = startIndex, end = endIndex)
                        }
                    }
                    is UnderlineSpan -> {
                        addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start = startIndex, end = endIndex)
                    }
                    is URLSpan -> {
                        addStyle(style = hyperlinkStyle.toSpanStyle(), start = startIndex, end = endIndex)
                        addStringAnnotation(tag = Tag.Hyperlink.name, annotation = span.url, start = startIndex, end = endIndex)
                    }
                }
            }
        }
    }

    ClickableText(
        annotatedText,
        modifier = modifier,
        style = style,
        softWrap = softWrap,
        overflow = overflow,
        maxLines = maxLines,
        onTextLayout = onTextLayout
    ) {
        annotatedText.getStringAnnotations(tag = Tag.Hyperlink.name, start = it, end = it).firstOrNull()?.let {
            onHyperlinkClick(it.item)
        }
    }
}

private fun StyleSpan.toSpanStyle(): SpanStyle? {
    return when (style) {
        Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold)
        Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic)
        Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)
        else -> null
    }
}

private enum class Tag {
    Hyperlink
}

使用 import android.graphics.Typeface 来避免编译错误。 - Arpit Patel
这确实可以工作,但有一个注意事项。每当我们将字体系列添加到TextStyle中时,所有HTML格式都会被禁用。 - Zaraki596
它不能正确读取颜色标签(颜色与HTML中的不一致)+ 无法识别链接 - Сергей Беляков

5

3
这实际上是Google在他们的“迁移到Jetpack Compose”的codelab中推荐的解决方案。他们说:“由于Compose尚不能呈现HTML代码,因此您将使用AndroidView API以编程方式创建一个TextView来执行此操作。” 链接:https://developer.android.com/codelabs/jetpack-compose-migration?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-migration#8 - Luke Needham

4

参照使用HTML标记进行样式设置的指南,结合Sven的答案,我编写了这个函数,可以像内置的stringResource()函数一样使用:

/**
 * Load a styled string resource with formatting.
 *
 * @param id the resource identifier
 * @param formatArgs the format arguments
 * @return the string data associated with the resource
 */
@Composable
fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
    val text = stringResource(id, *formatArgs)
    val spanned = remember(text) {
        HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY)
    }
    return remember(spanned) {
        buildAnnotatedString {
            append(spanned.toString())
            spanned.getSpans(0, spanned.length, Any::class.java).forEach { span ->
                val start = spanned.getSpanStart(span)
                val end = spanned.getSpanEnd(span)
                when (span) {
                    is StyleSpan -> when (span.style) {
                        Typeface.BOLD ->
                            addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end)
                        Typeface.ITALIC ->
                            addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end)
                        Typeface.BOLD_ITALIC ->
                            addStyle(
                                SpanStyle(
                                    fontWeight = FontWeight.Bold,
                                    fontStyle = FontStyle.Italic,
                                ),
                                start,
                                end,
                            )
                    }
                    is UnderlineSpan ->
                        addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end)
                    is ForegroundColorSpan ->
                        addStyle(SpanStyle(color = Color(span.foregroundColor)), start, end)
                }
            }
        }
    }
}

2
我在Nieto's answer的基础上构建了我的解决方案。
我希望能够通过使用Compose主题属性来为HtmlText中的文本设置样式。
因此,我添加了colorstyle参数,这也是Text提供的参数,并将它们翻译成了TextView的参数。
以下是我的解决方案:
@Composable
fun HtmlText(
    html: String,
    modifier: Modifier = Modifier,
    color: Color = Color.Unspecified,
    style: TextStyle = LocalTextStyle.current,
) {
    val textColor = color
        .takeOrElse { style.color }
        .takeOrElse { LocalContentColor.current.copy(alpha = LocalContentAlpha.current) }
        .toArgb()

    val density = LocalDensity.current

    val textSize = with(density) {
        style.fontSize
            .takeOrElse { LocalTextStyle.current.fontSize }
            .toPx()
    }

    val lineHeight = with(density) {
        style.lineHeight
            .takeOrElse { LocalTextStyle.current.lineHeight }
            .roundToPx()
    }

    val formattedText = remember(html) {
        HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
    }

    AndroidView(
        modifier = modifier,
        factory = { context ->
            AppCompatTextView(context).apply {
                setTextColor(textColor)

                // I haven't found out how to extract the typeface from style so I created my_font_family.xml and set it here
                typeface = ResourcesCompat.getFont(context, R.font.my_font_family)
                setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)

                if (style.lineHeight.isSp) {
                    this.lineHeight = lineHeight
                } else {
                     // Line Height could not be set
                }
        },
        update = { it.text = formattedText }
    )
}

它崩溃了,并显示java.lang.IllegalStateException:只有Sp可以转换为Px。 - MoeinDeveloper
@MoeinDeveloper 你能提供堆栈跟踪吗? - Peter F
1
只有在使用style.lineHeight.roundToPx()时才能正常工作,正如@MoeinDeveloper所提到的那样,否则会崩溃。这里无法添加堆栈跟踪(字符限制),但错误是: java.lang.IllegalStateException:只有Sp可以转换为Px at androidx.compose.ui.unit.Density$DefaultImpls.toPx--R2X_6o(Density.kt:88) at androidx.compose.ui.unit.DensityImpl.toPx--R2X_6o(Density.kt:36) at androidx.compose.ui.unit.Density$DefaultImpls.roundToPx--R2X_6o(Density.kt:96) at androidx.compose.ui.unit.DensityImpl.roundToPx--R2X_6o(Density.kt:36) - Sepideh Vatankhah
2
感谢@Sepideh Vatankhah和MoeinDeveloper的反馈!如果在样式中未设置lineHeight,程序将崩溃。 我更新了解决方案: 1.确定行高和字体大小时,请使用全局回退。 2.如果没有全局定义行高,请不要设置行高。 3.使用toArgb()将Compose Color转换为Color Int(并删除辅助函数) 4.直接使用AppCompatTextView设置行高(并删除帮助器函数) - Peter F

1

转换为字符串:

Text(text = Html.fromHtml(htmlSource).toString())

1
它不能与<b>标签一起使用。 - LAO

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接