Android Compose: cómo usar etiquetas HTML en una vista de texto

9 minutos de lectura

avatar de usuario
William

Tengo una cadena de una fuente externa que contiene etiquetas HTML en este formato: “Hola, estoy en negrita texto”

Antes de Compose, tenía CDATA al comienzo de mi cadena HTML, usaba Html.fromHtml() para convertir a Spanned y lo pasaba a TextView. El TextView tendría la palabra negrita en negrita.

He intentado replicar esto con Compose pero no puedo encontrar los pasos exactos que me permitan lograrlo con éxito.

Cualquier sugerencia es recibida con gratitud.

  • Necesitarías convertir ese HTML en un AnnotatedString. AFAIK, actualmente no hay HTML -> AnnotatedString convertidores o Spanned -> AnnotatedString convertidores Hay un par de Markdown -> AnnotatedString convertidores, pero es poco probable que eso ayude en este caso particular. Es posible que deba crear un convertidor adecuado usted mismo.

    – CommonsWare

    5 de marzo de 2021 a las 15:11

  • @CommonsWare Esa no es realmente la respuesta que esperaba, pero gracias por una respuesta tan rápida. Me ahorrará muchas búsquedas infructuosas. Gracias.

    – William

    5 de marzo de 2021 a las 16:35

  • Aquí hay una solución: stackoverflow.com/a/69902377/753632

    – Juan

    10 de noviembre de 2021 a las 5:37

  • Creo que esta es la solicitud de función relacionada en el lado de Android, ¿podría votar si se ve afectado? tematracker.google.com/issues/174348612

    – Alejandra

    27 de mayo a las 6:41


avatar de usuario
Nieto

Todavía no hay Composable oficial para hacer esto. Por ahora estoy usando un AndroidView con un TextView adentro. No es la mejor solución, pero es simple y eso resuelve el problema.

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

Si tiene etiquetas en el HTML, debe configurar el TextView propiedad movementMethod = LinkMovementMethod.getInstance() para hacer clic en los enlaces.

Estoy usando esta pequeña función de ayuda que convierte algunos de los Span (Expandido) en un SpanStyle (AnnotatedString/Compose) reemplazo.

    /**
     * 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 = [email protected]
        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)
            }
        }
    }

avatar de usuario
El vagabundo

Dado que estoy usando un proyecto multiplataforma de Kotlin con Android Jetpack Compose y JetBrains Compose for Desktop, realmente no tengo la opción de recurrir a TextView de Android.

Así que me inspiré en la respuesta de turbohenoch e hice todo lo posible para expandirla para poder interpretar múltiples etiquetas de formato HTML (posiblemente anidadas).

El código definitivamente se puede mejorar, y no es para nada resistente a los errores de HTML, pero lo probé con texto que contenía <u> y <b> etiquetas y funciona bien para eso al menos.

Aquí está el código:

/**
 * 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 androide,html,android-jetpack-compose,expandible,androide,html,android-jetpack-compose,expandible Map and forget to add it 
        //to this function.
        else -> throw IllegalArgumentException("Tag $tag is not valid.")
    }
}

Hice todo lo posible para hacer comentarios claros, pero aquí hay una explicación rápida. los tags variable es un mapa de las etiquetas a rastrear, siendo las claves las etiquetas de apertura y los valores sus correspondientes etiquetas de cierre. Cualquier cosa aquí también debe ser manejada en el tagToStyle() para que el código pueda obtener un SpanStyle adecuado para cada etiqueta.

Luego escanea recursivamente la cadena de entrada, buscando etiquetas de apertura y cierre rastreadas.

Si la cadena que se le da comienza con una etiqueta de cierre, mostrará el SpanStyle aplicado más recientemente (eliminándolo del texto agregado a partir de ese momento) y llamará a la función recursiva en la cadena con esa etiqueta eliminada.

Si la cadena que se le da comienza con una etiqueta de apertura, empujará el SpanStyle correspondiente (usando tagToStyle()) y luego llame a la función recursiva en la cadena con esa etiqueta eliminada.

Si la cadena que se le da no comienza con una etiqueta de cierre o de apertura, pero lo hace contiene al menos uno de los dos, encontrará la primera aparición de cualquier etiqueta rastreada (apertura o cierre), normalmente agregará todo el texto en la Cadena dada hasta ese índice, y luego llamará a la función recursiva en la Cadena comenzando en el índice de la primera etiqueta rastreada que encuentra.

Si la cadena que se le da no tiene ninguna etiqueta, simplemente se agregará normalmente, sin agregar ni eliminar ningún estilo.

Como estoy usando esto en una aplicación que se está desarrollando activamente, probablemente continuaré actualizándola según sea necesario. Suponiendo que no haya cambios drásticos, la última versión debería estar disponible en su repositorio GitHub.

Para un caso de uso simple, puede hacer algo como esto:

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

Y usa este AnnotatedString en @Composable

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

Por supuesto, esto se vuelve más complicado a medida que intenta admitir más etiquetas.

avatar de usuario
Víctor Albertos

puedes intentar redactar-htmlque es una biblioteca de Android que proporciona soporte HTML para textos de Jetpack Compose.

como el componible Text El diseño no proporciona ningún soporte HTML. Esta biblioteca llena ese vacío al exponer el componible HtmlText diseño, que está construido sobre el Text diseño y el Span/Spannable Clases de Android (la implementación se basa en la respuesta de @Sven). Su API es la siguiente:

HtmlText(
    text = htmlString,
    linkClicked = { link ->
        Log.d("linkClicked", link)
    }
)

Y estos son todos los parámetros disponibles que le permiten cambiar el comportamiento predeterminado:

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 admite casi tantas etiquetas HTML como android.widget.TextView hace, con excepción de <img> etiquetar y <ul>siendo este último parcialmente soportado, como HtmlText
representa correctamente los elementos de la lista pero no agrega la viñeta (•)

  • Esta respuesta debería estar más cerca de la parte superior.

    – acmpo6ou

    4 jun a las 19:00

avatar de usuario
William

Compose Text() aún no es compatible con HTML. Recién se fue a Beta, así que tal vez llegue.

La solución que implementamos por ahora (y esto no es perfecto) fue recurrir al antiguo control TextView, que Compose le permitirá hacer.

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

  • Esta respuesta debería estar más cerca de la parte superior.

    – acmpo6ou

    4 jun a las 19:00

Siguiendo la guía de Estilo con marcado HTMLy combinándolo con la respuesta de Sven, se me ocurrió esta función que se puede usar como la función integrada stringResource() función:

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

¿Ha sido útil esta solución?