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

Wil*_*iam 9 html android spannable android-jetpack-compose

我有来自外部源的字符串,其中包含以下格式的 HTML 标记:“你好,我是 <b> 粗体</b> 文本”

在 Compose 之前,我会在 HTML 字符串的开头使用 CDATA,使用 Html.fromHtml() 转换为 Spanned 并将其传递给 TextView。TextView 会将粗体字加粗。

我试图用 Compose 复制这个,但我找不到让我成功实现它的确切步骤。

任何建议都非常感谢。

Sve*_*ven 48

我正在使用这个小辅助函数,它将一些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)
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)

这是有关如何使用它的示例。

val spannableString = SpannableStringBuilder("<b>Hello</b> <i>World</i>").toString()
val spanned = HtmlCompat.fromHtml(spannableString, HtmlCompat.FROM_HTML_MODE_COMPACT)

Text(text = spanned.toAnnotatedString())
Run Code Online (Sandbox Code Playgroud)

  • URLSpan 怎么样? (2认同)

Nie*_*eto 45

目前还没有官方的 Composable 可以做到这一点。现在我使用的是 AndroidView,里面有 TextView。这不是最好的解决方案,但它很简单并且可以解决问题。

@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) }
    )
}
Run Code Online (Sandbox Code Playgroud)

如果 HTML 中有标签,则需要设置属性TextViewmovementMethod = LinkMovementMethod.getInstance()使链接可单击。


The*_*rer 20

由于我使用的是带有 Android Jetpack Compose 和 JetBrains Compose for Desktop 的 Kotlin 多平台项目,因此我实际上没有选择只使用 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.")
    }
}
Run Code Online (Sandbox Code Playgroud)

我已尽力做出明确的评论,但这里有一个简单的解释。该tags变量是要跟踪的标签的映射,其中键是开始标签,值是其相应的结束标签。这里的任何内容都需要在函数中处理tagToStyle(),以便代码可以为每个标签获取正确的 SpanStyle。

然后,它递归地扫描输入字符串,查找跟踪的开始和结束标签。

如果给定的字符串以结束标记开头,它将弹出最近应用的 SpanStyle(从此后附加的文本中将其删除),并在删除该标记的字符串上调用递归函数。

如果给定的字符串以开始标记开头,它将推送相应的 SpanStyle (使用tagToStyle()),然后在删除该标记的字符串上调用递归函数。

如果给定的字符串不以结束标记或开始标记开头,但至少包含其中一个,则它将找到任何跟踪标记(开始或结束)的第一次出现,通常将所有文本附加到给定的位置串起来直到该索引,然后从找到的第一个跟踪标签的索引开始对字符串调用递归函数。

如果给定的字符串没有任何标签,它只会正常附加,而不添加或删除任何样式。

由于我在正在积极开发的应用程序中使用它,因此我可能会根据需要继续更新它。假设没有发生重大变化,最新版本应该可以在其GitHub 存储库上找到。


Víc*_*tos 12

您可以尝试compose-html,它是一个 Android 库,为 Jetpack Compose 文本提供 HTML 支持。

\n

由于可组合Text布局不提供任何 HTML 支持。该库通过公开可组合HtmlText布局来填补这一空白,该布局构建在布局TextSpan/Spannable(实现基于 @Sven 答案)。其API如下:

\n
HtmlText(\n    text = htmlString,\n    linkClicked = { link ->\n        Log.d("linkClicked", link)\n    }\n)\n
Run Code Online (Sandbox Code Playgroud)\n

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

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

HtmlText支持几乎与一样多的HTML 标签android.widget.TextView<img>,但标签 和除外<ul>,后者部分受支持,如HtmlText\n正确呈现列表的元素,但不添加项目符号 (\xe2\x80\xa2)

\n

  • 这个答案应该更接近顶部 (2认同)

tur*_*och 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
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

并在 @Composable 中使用这个 AnnotatedString

Text(text = "Hello, I am <b> bold</b> text".parseBold())
Run Code Online (Sandbox Code Playgroud)

当然,当您尝试支持更多标签时,这会变得更加棘手。

如果您使用字符串资源,则使用类似的添加标签 -

<string name="intro"><![CDATA[Hello, I am <b> bold</b> text]]></string>
Run Code Online (Sandbox Code Playgroud)


小智 7

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

@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
}
Run Code Online (Sandbox Code Playgroud)


Wil*_*iam 5

Compose Text() 尚不支持 HTML。它刚刚进入 Beta 阶段,所以也许它会到来。

我们现在实现的解决方案(这并不完美)是依靠旧的 TextView 控件,Compose 将允许您这样做。

https://developer.android.com/jetpack/compose/interop#views-in-compose

https://proandroiddev.com/jetpack-compose-interop-part-1-using-traditional-views-and-layouts-in-compose-with-androidview-b6f1b1c3eb1

  • 这实际上是 Google 在其“迁移到 Jetpack Compose”代码实验室中推荐的解决方案。他们说“由于 Compose 尚无法呈现 HTML 代码,因此您将通过编程方式创建一个 TextView,以使用 AndroidView API 来完成此操作。” 这是链接: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 (3认同)