Android Compose: How to use HTML tags in a Text view
Asked Answered
H

19

72

I have as string from an outside source that contains HTML tags in this format: "Hello, I am <b> bold</b> text"

Before Compose I would have CDATA at the start of my HTML String, used Html.fromHtml() to convert to a Spanned and passed it to the TextView. The TextView would them have the word bold in bold.

I have tried to replicated this with Compose but I can't find the exact steps to allow me to achieve it successfully.

Any suggestions are gratefully received.

Histaminase answered 5/3, 2021 at 15:0 Comment(4)
You would need to convert that HTML into an AnnotatedString. AFAIK, currently there are no HTML -> AnnotatedString converters or Spanned -> AnnotatedString converters. There are a couple of Markdown -> AnnotatedString converters, but that is unlikely to help in this particular case. You may need to create a suitable converter yourself.Esoterica
@Esoterica That's not really the answer I was hoping for but thanks for such a quick reply. It'll save me a lot of fruitless searching. Thank you.Histaminase
Here's one solution: https://mcmap.net/q/275887/-jetpack-compose-bold-only-string-placeholderProliferate
Compose now has a native solution. It's currently available in compose 1.7.0-beta01, so it should be available in the stable release channel soon. See answer below: https://mcmap.net/q/273282/-android-compose-how-to-use-html-tags-in-a-text-viewCelaeno
P
59

I am using this little helper function that converts some of the Span (Spanned) into a SpanStyle (AnnotatedString/Compose) replacement.

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

Here is an example on how to use it.

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

Text(text = spanned.toAnnotatedString())
Pridgen answered 26/8, 2021 at 9:11 Comment(7)
Kindly add an example of implementation.Miraculous
how about URLSpan ?Rotenone
simple and effective answerGemsbok
fyi: you can't use it when text from string resourceTenebrous
incase any one searching for strike out. is StrikethroughSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)Milzie
It works nicely until you try to pass to Text also style with fontWeightAkim
@if_alan, actually you can also use the @Pridgen and @solamour solution with resource strings (strings from strings.xml file). Basically you need to embrace the string between <![CDATA[ … ]]> like this sample: <string name="minor_test"><![CDATA[Just a<b>minor</b> test]]></string>.Demurrage
I
49

There is yet no official Composable to do this. For now i'm using an AndroidView with a TextView inside. Not the best solution, but it's simple and that solves the problem.

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

If you have tags in the HTML you need to set the TextView property movementMethod = LinkMovementMethod.getInstance() to make the links clickable.

Internist answered 15/7, 2021 at 15:55 Comment(0)
G
26

Updated answer for 2024

I'm keeping the original answer below since it might still be useful for some people, but I recently found a library that takes care of all the parsing and converting for you: Compose Rich Editor.

This library is multiplatform and supports JVM, Android, iOS (x64, arm64, arm64 simulator), JS, and WasmJS at the time of writing.

Implement it in your common build.gradle.kts:

...
kotlin {
    ...
    sourceSets {
        val commonMain by getting {
            ...
            implementation("com.mohamedrejeb.richeditor:richeditor-compose:1.0.0-rc04")
        }
        ...
    }
    ...
}
...

Make a simple Composable function to convert an HTML string to an AnnotatedString:

@Composable
fun String.toRichHtmlString(): AnnotatedString {
    val state = rememberRichTextState()
    
    LaunchedEffect(this) {
        state.setHtml(this@toRichHtmlString)
    }
    
    return state.annotatedString
}

Then call it when passing an HTML string to a Text component:

Text(text = someHtmlString.toRichHtmlString())

Original Answer

Since I'm using a Kotlin Multiplatform project with Android Jetpack Compose and JetBrains Compose for Desktop, I don't really have the option of just falling back to Android's TextView.

So I took inspiration from turbohenoch's answer and did my best to expand it to be able to interpret multiple (possibly nested) HTML formatting tags.

The code can definitely be improved, and it's not at all robust to HTML errors, but I did test it with text that contained <u> and <b> tags and it works fine for that at least.

Here's the code:

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

I did my best to make clear comments, but here's a quick explanation. The tags variable is a map of the tags to track, with the keys being the opening tags and the values being their corresponding closing tags. Anything here needs to also be handled in the tagToStyle() function, so that the code can get a proper SpanStyle for each tag.

It then recursively scans the input String, looking for tracked opening and closing tags.

If the String it's given starts with a closing tag, it'll pop the most recently-applied SpanStyle (removing it from text appended from then on) and call the recursive function on the String with that tag removed.

If the String it's given starts with an opening tag, it'll push the corresponding SpanStyle (using tagToStyle()) and then call the recursive function on the String with that tag removed.

If the String it's given doesn't start with either a closing or opening tag, but does contain at least one of either, it'll find the first occurrence of any tracked tag (opening or closing), normally append all text in the given String up until that index, and then call the recursive function on the String starting at the index of the first tracked tag it finds.

If the String it's given doesn't have any tags, it'll just append normally, without adding or removing any styling.

Since I'm using this in an app being actively developed, I'll probably continue to update it as needed. Assuming nothing drastic changes, the latest version should be available on its GitHub repository.

Graecize answered 4/7, 2021 at 2:57 Comment(0)
S
13

You may try compose-html, which is an Android library that provides HTML support for Jetpack Compose texts.

As the composable Text layout doesn't provide any HTML support. This library fills that gap by exposing the composable HtmlText layout, which is built on top of the Text layout and the Span/Spannable Android classes (the implementation is based in @Sven answer). Its API goes as follows:

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

And these are all the available parameters that allows you to change the default behaviour:

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 supports almost as many HTML tags as android.widget.TextView does, with the exception of <img> tag and <ul>, being the latter partially supported, as HtmlText renders properly the elements of the list but it does not add the bullet (•)

Substitutive answered 30/1, 2022 at 10:48 Comment(2)
This answer should be closer to the topStott
@acmpo6ou Native solutions should be on top, not third-party libraries, which are not always possible to build into productionLikelihood
J
10

For simple use case you can do something like this:

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

And use this AnnotatedString in @Composable

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

Of course this gets trickier as you try to support more tags.

If you are using string resource then use add tag like that -

<string name="intro"><![CDATA[Hello, I am <b> bold</b> text]]></string>
Jacaranda answered 13/5, 2021 at 13:22 Comment(1)
Thank you for saving my time, this can also apply to multiplatform projects.Cordiality
C
8

April 2024 Update:

They have started adding support into compose for this directly.

See: https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.7.0-alpha06

Added parseAsHtml method for styled strings: it allows to convert a string marked with HTML tags into AnnotatedString. Note that not all tags are supported, for example you won't be able to display bullet lists yet. (I84d3d)

And then: https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.7.0-alpha07

  • String.parseAsHtml renamed to AnnotatedString.Companion.fromHtml (I43dcd)
  • Added styling arguments (linkStyle, focusedLinkStyle, hoveredLinkStyle) and a link interaction listener to the parseAsHtml method. When parsing the HTML-tagged string with tags, the method will construct a LinkAnnotation.Url for each such tag and pass the styling objects and link interaction listener to each annotation. (I7c977)
Cylix answered 9/5 at 21:57 Comment(1)
This should be the accepted answer. Here the function reference: developer.android.com/reference/kotlin/androidx/compose/ui/text/…Cedrickceevah
H
7

Here is my solution that also supports hyperlinks:

@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
}
Harmonyharmotome answered 18/1, 2022 at 14:33 Comment(3)
use import android.graphics.Typeface to avoid compilation error.Drear
This does work but with the following caveat. Whenever we add font family to the TextStyle, All the HTML formatting is disabledWearing
It does not correctly read color tags (the colors are not the ones that came in html) + does not see the linkClarabelle
H
5

Compose Text() does not support HTML yet. It's only just gone to Beta so maybe it will arrive.

The solution we implemented for now (and this is not perfect) was to fall back on the old TextView control, which Compose will allow you to do.

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

Histaminase answered 10/3, 2021 at 22:28 Comment(1)
This is actually the solution Google recommend in their 'Migrating to Jetpack Compose' codelab. They say "As Compose is not able to render HTML code yet, you'll create a TextView programmatically to do exactly that using the AndroidView API." Here's the link: developer.android.com/codelabs/…Ahmedahmedabad
E
4

Following the guide on Styling with HTML markup, and combining it with Sven's answer, I came up with this function that can be used like the built-in stringResource() function:

/**
 * 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)
                }
            }
        }
    }
}
Expiry answered 3/9, 2021 at 13:31 Comment(0)
T
3

I wrote a composable that can render HTML tags it currently supports these tags:

<b> <u> <i> <p> <br> <h1> <h2> <h3> <h4> <h5> <h6>

You can do more if you need:

@Composable
fun HtmlStyledText(htmlText: String, style: TextStyle, color: Color) {
    val text = htmlText.replace("\\'", "\'")
        .removePrefix("<![CDATA[")
        .removeSuffix("]]>")
    val annotatedString = buildAnnotatedString {
        val regex = Regex("<(/?)(b|i|u|h[1-6]|p|br)>")
        var lastIndex = 0

        pushStyle(
            SpanStyle(
                color = color,
                fontSize = style.fontSize,
                fontFamily = style.fontFamily,
                fontWeight = style.fontWeight,
                fontStyle = style.fontStyle
            )
        )

        regex.findAll(text).forEach { matchResult ->
            val index = matchResult.range.first

            val appendingText = text.substring(lastIndex, index)
            if (appendingText.length != 1 || !appendingText[0].isWhitespace()) {
                append(text.substring(lastIndex, index))
            }

            when (val tag = matchResult.value) {
                "<b>" -> pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
                "</b>" -> pop()
                "<i>" -> pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
                "</i>" -> pop()
                "<u>" -> pushStyle(SpanStyle(textDecoration = TextDecoration.Underline))
                "</u>" -> pop()
                "<p>" -> {
                    append("\n")
                    pushStyle(ParagraphStyle(lineHeight = style.fontSize * 1.5f))
                }

                "</p>" -> {
                    pop()
                    append("\n")
                }

                "<br>" -> append("\n")
                in listOf("<h1>", "<h2>", "<h3>", "<h4>", "<h5>", "<h6>") -> {
                    val fontSize = when (tag) {
                        "<h1>" -> style.fontSize * 2f
                        "<h2>" -> style.fontSize * 1.5f
                        "<h3>" -> style.fontSize * 1.25f
                        "<h4>" -> style.fontSize * 1.15f
                        "<h5>" -> style.fontSize * 1.05f
                        else -> style.fontSize // For <h6>
                    }
                    pushStyle(SpanStyle(fontSize = fontSize, fontWeight = FontWeight.Bold))
                }

                in listOf("</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>") -> pop()
            }

            lastIndex = matchResult.range.last + 1
        }

        append(text.substring(lastIndex, text.length))
    }

    Text(text = annotatedString, style = style)
}

Usage:

HtmlStyledText(
    htmlText = "<b>Your <i>HTML</i> <u>text</u></b>",
    style = FontStyles.bodyRegular(),
    color = AppColors.primary_title_black
)

Output:

( Your HTML text )

If you want to store HTML and retrieve it from strings.xml, you must wrap it in CDATA like below:

<![CDATA[<b>Your <i>HTML</i> <u>text</u></b>]]>

Thanks @Kristi for mentioning this

Thenna answered 16/1 at 21:19 Comment(3)
Doesn't support from string resourcesOffside
unless you have: <string name="intro"><![CDATA[Hello, I am <b> bold</b> text]]></string>Offside
As you check it also takes care of CDATA as well I will update the answer as well. thanks for mentioningThenna
I
2

I built my solution on Nieto's answer.

I wanted to be able to style the text in HtmlText by using the Compose theme attributes.

So I added the parameters color and style, which Text also offers, and translated them for TextView.

Here is my solution:

@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 }
    )
}
Innuendo answered 3/12, 2021 at 23:39 Comment(4)
It crashes, and says java.lang.IllegalStateException: Only Sp can convert to PxLabourer
@Labourer Can you provide the stack trace?Innuendo
It works fine just when you use style.lineHeight.roundToPx() as @Labourer mentioned it crashes, here is not possible to add the stack trace(the character limitation), though the error is: java.lang.IllegalStateException: Only Sp can convert to 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)Tideway
Thanks for the feedback @Sepideh Vatankhah and MoeinDeveloper! The crash would happen if lineHeight is not set in style. I updated the solution: 1. Use global fallbacks when determining line height and font size. 2. Don't set line height in case line height isn't defined globally either. 3. Use toArgb() to convert Compose Color to Color Int (and remove helper functions) 4. Set line height directly by using AppCompatTextView (and remove helper function)Innuendo
S
1

Convert to String:

Text(text = Html.fromHtml(htmlSource).toString())
Skat answered 28/2, 2022 at 5:4 Comment(1)
it doesn't work with <b> tagsLuckett
E
1

NOTE: Nesting the same tag is not supported (<b>Foo<b>Bar</b></b>)

enum class AnnotatedStringTags(tag: String, val style: SpanStyle) {
    BOLD("b", SpanStyle(fontWeight = FontWeight.Bold)),
    ITALIC("i", SpanStyle(fontStyle = FontStyle.Italic)),
    UNDERLINE("u", SpanStyle(textDecoration = TextDecoration.Underline));

    val startTag = "<$tag>"
    val endTag = "</$tag>"

    companion object {
        val ALL = values()
    }
}

val REGEX_BR = """<\\?br\\?>""".toRegex()

fun String.toAnnotatedString() = buildAnnotatedString { fromHtml(replace(REGEX_BR, "\n")) }

private fun AnnotatedString.Builder.fromHtml(string: String) {
    var currentString = string
    while (currentString.isNotEmpty()) {
        val tagPositionPair = AnnotatedStringTags.ALL.asSequence()
            .map { it to currentString.indexOf(it.startTag) }
            .filter { (_, idx) -> idx >= 0 }
            .minByOrNull { (_, idx) -> idx }

        if (tagPositionPair == null) {
            // No more tags found
            append(currentString)
            return
        }
        val (tag, idx) = tagPositionPair
        val endIdx = currentString.indexOf(tag.endTag)
        if (endIdx < 0)
            throw IllegalStateException("Cannot find end tag for starting tag ${tag.startTag}")
        if (idx > 0)
            append(currentString.substring(0 until idx))

        withStyle(tag.style) {
            append(
                buildAnnotatedString {
                    fromHtml(currentString.substring((idx + tag.startTag.length) until endIdx))
                }
            )
        }
        currentString = currentString.substring(endIdx + tag.endTag.length)
    }
}
Exonerate answered 7/9, 2022 at 9:4 Comment(0)
T
1

You can Also use Google Accompanist ... it's a layer of libraries on top of Jetpack Compose with extra functionality that with time will become part of the official library eventually.

Here some info about how to use Accompanist WebView, very easy very powerful -> https://code.luasoftware.com/tutorials/android/android-show-html-via-webview

And here the official documentation -> https://google.github.io/accompanist/web/

Basically just import com.google.accompanist:accompanist-webview at your project (using the right version for the current Jetpack compose library) and then just add code like:

val content = "<h1>Hello world!</h1>"
val webViewState = rememberWebViewStateWithHTMLData(data = content)
WebView(
    state = webViewState,
    onCreated = {
        it.settings.javaScriptEnabled = true
    }
)

And you are good to go !!

Thud answered 23/12, 2023 at 12:8 Comment(0)
I
0

Thanks to @Nieto for your answer. This is the improvised version with clickable links as per his suggestion

@Composable
fun HtmlText(html: String, modifier: Modifier = Modifier) {
    AndroidView(
        modifier = modifier,
        factory = { context ->
            val textView = TextView(context)
            textView.movementMethod = LinkMovementMethod.getInstance()
            textView
        },
        update = { it.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT) }
    )
}
Ilianailine answered 12/10, 2022 at 9:13 Comment(0)
R
0

In my case I needed to use placeholders in string resources with html tags for bold text in compose.

I solved it with the method below htmlStringResource which is basing on Spanned.toAnnotatedString() from Svens answer

@Composable
fun htmlStringResource(@StringRes resourceId: Int, vararg formatArgs: Any): AnnotatedString {
   val htmlString = stringResource(id = resourceId, *formatArgs)
   return HtmlCompat.fromHtml(htmlString, HtmlCompat.FROM_HTML_MODE_COMPACT).toAnnotatedString()
}

<string name="price"><![CDATA[<b>%1$s€</b> per year]]></string>

Text(text = htmlStringResource(resourceId = string.price, "39.90"))
Reverential answered 20/1, 2023 at 9:47 Comment(0)
L
0

For a very simple use case you might consider using regex, by matching tag blocks, consider the following:

fun annotatedStringFromHtml(html: String): AnnotatedString {
    // You could add other tags here, and handle the match in the loop
    val regex = "<[b]>(?<textContent>.*?)</[b]>".toRegex()
    val tags = regex.findAll(html)
        .associateBy { it.range.first }

    return buildAnnotatedString {
        var i = 0
        while (i < html.length) {
            val tag = tags[i]
            if (tag != null) {
                if (tag.value.startsWith("<b")) {
                    val textContent = tag.groups["textContent"]!!.value
                    withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
                        append(textContent)
                    }
                }
                // You could handle other cases by editing the regex above
                if (tag.value.startsWith("<a")) {
                    TODO()
                }
                // Move cursor at the end of match
                i = (tag.range.last + 1)
                continue
            }
            // No special tag a this position, text will be appended as is.
            append(html[i])
            i++
        }
    }
}
Lovash answered 10/12, 2023 at 21:43 Comment(0)
P
0

preview

fun String.annotatedStringHtmlParser(
    boldTag: String = "++",
    underlineTag: String = "__",
    italicTag: String = "//",
) =
    buildAnnotatedString {
        val pattern =
            "(${Regex.escape(boldTag)}|${Regex.escape(underlineTag)}|${Regex.escape(italicTag)})(.*?)(\\1)".toRegex()
        var lastIndex = 0
        val text = this@annotatedStringHtmlParser
        pattern.findAll(text).forEach { result ->
            val (tag, content) = result.destructured
            append(text.substring(lastIndex, result.range.first))
            val start = length
            append(content)
            val end = length
            when (tag) {
                boldTag -> addStyle(
                    style = SpanStyle(fontWeight = FontWeight.Bold),
                    start = start,
                    end = end,
                )

                underlineTag -> addStyle(
                    style = SpanStyle(textDecoration = TextDecoration.Underline),
                    start = start,
                    end = end,
                )

                italicTag -> addStyle(
                    style = SpanStyle(fontStyle = FontStyle.Italic),
                    start = start,
                    end = end,
                )
            }
            lastIndex = result.range.last + 1
        }
        append(text.substring(lastIndex, text.length))
    }

@Preview
@Composable
fun PreviewAnnotatedStringWithHtml() {
    OvTrackerTheme {
        Surface {
            Text(
                "__underline__, ++Bold++, //Italic//, Bold Underline".annotatedStringHtmlParser()
            )
        }
    }
}
Persinger answered 15/2 at 12:29 Comment(0)
F
0

Like @TheWanderer, I needed a solution for Kotlin Multiplatform. Thanks to Ksoup, I didn't have to write the HTML parser myself and instead just implemented tag handlers:

fun String.parseHtml(linkColor: Color, requiresHtmlDecode: Boolean = true): AnnotatedString {
    val string = AnnotatedString.Builder()

    val handler = KsoupHtmlHandler
        .Builder()
        .onOpenTag { name, attributes, isImplied ->
            when (name) {
                "p", "span" -> {}
                "br" -> string.append('\n')
                "a" -> {
                    string.pushStringAnnotation("link", attributes["href"] ?: "")
                    string.pushStyle(
                        SpanStyle(
                            color = linkColor,
                            textDecoration = TextDecoration.Underline
                        )
                    )
                }

                "b" -> string.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
                "u" -> string.pushStyle(SpanStyle(textDecoration = TextDecoration.Underline))
                "i" -> string.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
                "s" -> string.pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))

                else -> println("onOpenTag: Unhandled span $name")
            }
        }
        .onCloseTag { name, isImplied ->
            when (name) {
                "p" -> string.append(' ')
                "span", "br" -> {}
                "b", "u", "i", "s" -> string.pop()
                "a" -> {
                    string.pop() // corresponds to pushStyle
                    string.pop() // corresponds to pushStringAnnotation
                }

                else -> println("onCloseTag: Unhandled span $name")
            }
        }
        .onText { text ->
            println("text=$text")
            string.append(text)

        }
        .build()


    val ksoupHtmlParser = KsoupHtmlParser(handler)

    // Pass the HTML to the parser (It is going to parse the HTML and call the callbacks)
    val html = if (requiresHtmlDecode) KsoupEntities.decodeHtml(this) else this
    ksoupHtmlParser.write(html)
    ksoupHtmlParser.end()

    return string.toAnnotatedString()
}

Ksoup's docs say that it decodes HTML entities by default, but somehow that didn't work for me, which is why by default this method decodes HTML entities before passing them to Ksoup's HTML parser.

You will probably want to use it with a ClickableText like this:

val annotatedString =
    feedItem.text.parseHtml(linkColor = MaterialTheme.colors.primary)
ClickableText(annotatedString, style = LocalTextStyle.current) { offset ->
    val url = annotatedString.getStringAnnotations(start = offset, end = offset)
        .firstOrNull()?.item
    if (url != null) uriHandler.openUri(url) else someDefaultClickHandler()
}

You'll need the following dependencies for this to work:

com.mohamedrejeb.ksoup:ksoup-html
com.mohamedrejeb.ksoup:ksoup-entities
Farkas answered 29/2 at 17:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.