【问题标题】:Android Compose: How to use HTML tags in a Text viewAndroid Compose:如何在文本视图中使用 HTML 标签
【发布时间】:2021-06-04 06:28:02
【问题描述】:

我有来自外部源的字符串,其中包含这种格式的 HTML 标记: "你好,我是粗体文字"

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

我已尝试使用 Compose 复制此内容,但找不到让我成功实现它的确切步骤。

欢迎提出任何建议。

【问题讨论】:

  • 您需要将该 HTML 转换为 AnnotatedString。 AFAIK,目前没有 HTML -> AnnotatedString 转换器或 Spanned -> AnnotatedString 转换器。有几个 Markdown -> AnnotatedString 转换器,但这在这种特殊情况下不太可能有帮助。您可能需要自己创建一个合适的转换器。
  • @CommonsWare 这不是我真正希望的答案,但感谢您如此快速的回复。它会为我节省很多徒劳的搜索。谢谢。
  • 这是一种解决方案:stackoverflow.com/a/69902377/753632

标签: android html android-jetpack-compose spannable


【解决方案1】:

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 代码,所以您将使用 AndroidView API 以编程方式创建一个 TextView 来完全做到这一点。”这是链接:developer.android.com/codelabs/…
【解决方案2】:

对于简单的用例,您可以这样做:

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 中使用这个 AnnotatedString

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

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

【讨论】:

    【解决方案3】:

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

    所以我从turbohenoch's answer 中获得灵感,并尽我所能将其扩展为能够解释多个(可能是嵌套的)HTML 格式标记。

    代码肯定可以改进,它对 HTML 错误一点也不健壮,但我确实使用包含 &lt;u&gt;&lt;b&gt; 标记的文本对其进行了测试,至少它可以正常工作。

    代码如下:

    /**
     * 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.")
        }
    }
    

    我已尽力使 cmets 清楚,但这里有一个简单的解释。 tags 变量是要跟踪的标签的映射,键是开始标签,值是它们对应的结束标签。这里的任何事情也需要在tagToStyle()函数中处理,这样代码才能为每个标签获取一个合适的SpanStyle。

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

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

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

    如果给定的字符串不是以结束标记或开始标记开头,但 确实 包含其中至少一个,它将找到任何跟踪标记的第一次出现(开始或关闭),通常将给定字符串中的所有文本追加到该索引,然后从它找到的第一个跟踪标记的索引开始调用字符串上的递归函数。

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

    由于我在一个正在积极开发的应用程序中使用它,我可能会根据需要继续更新它。假设没有重大变化,最新版本应该可以在其GitHub repository 上获得。

    【讨论】:

      【解决方案4】:

      目前还没有官方的 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) }
          )
      }
      

      【讨论】:

        【解决方案5】:

        我正在使用这个小助手函数,它将一些 Span(跨接)转换为 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)
                    }
                }
            }
        

        【讨论】:

          【解决方案6】:

          按照Styling with HTML markup上的指南,结合Sven's answer,我想出了这个可以像内置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)
                          }
                      }
                  }
              }
          }
          

          【讨论】:

            【解决方案7】:

            我在Nieto's answer 上构建了我的解决方案。

            我希望能够使用 Compose 主题属性为 HtmlText 中的文本设置样式。

            所以我添加了Text 也提供的参数colorstyle,并将它们翻译为TextView

            这是我的解决方案:

            @Composable
            fun HtmlText(
                html: String,
                modifier: Modifier = Modifier,
                color: Color = MaterialTheme.colors.onSurface,
                style: TextStyle = LocalTextStyle.current,
            ) {
                val density = LocalDensity.current
                val textColor = color.toClassicColor()
                val textSize = with(density) { style.fontSize.toPx() }
            
                val formattedText = remember(html) {
                    HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY)
                }
            
                AndroidView(
                    modifier = modifier,
                    factory = { context ->
                        TextView(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)
                            setLineHeightCompat(with(density) { style.lineHeight.roundToPx() })
                        }
                    },
                    update = { it.text = formattedText }
                )
            }
            
            private fun TextView.setLineHeightCompat(
                lineHeightPx: Int,
            ) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    lineHeight = lineHeightPx
                } else {
                    // This is copied directly from the setLineHeight() function
                    val fontHeight = paint.getFontMetricsInt(null)
                    if (lineHeight != fontHeight) {
                        setLineSpacing((lineHeight - fontHeight).toFloat(), 1f)
                    }
                }
            }
            
            private fun Color.toClassicColor() =
                toArgb().run {
                    android.graphics.Color.argb(
                        alpha.toIntColorValue(),
                        red.toIntColorValue(),
                        green.toIntColorValue(),
                        blue.toIntColorValue(),
                    )
                }
            
            private fun Float.toIntColorValue() = (this * 255.0f + 0.5f).toInt()
            

            【讨论】:

            • 它崩溃了,说 java.lang.IllegalStateException: Only Sp can convert to Px
            • @MoeinDeveloper 你能提供堆栈跟踪吗?
            【解决方案8】:

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

            @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
            }
            

            【讨论】:

              【解决方案9】:

              您可以尝试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 支持的HTML tags as android.widget.TextView does 几乎一样多,除了&lt;img&gt; 标签和&lt;ul&gt;,后者是部分支持的HtmlText 正确呈现列表的元素,但不添加项目符号 (•)

              【讨论】:

                猜你喜欢
                • 2023-04-10
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 2020-11-22
                • 1970-01-01
                • 2018-04-29
                • 2021-10-01
                • 1970-01-01
                相关资源
                最近更新 更多