Styling dynamic strings directly in Android xml with HTML markup

In Android, Strings are one of the most common Objects when working with views that show plain text. While implementing user interfaces, it happens often that the text requires some styling. For styled Strings, we are required to use CharSequences instead. Android supports some html tags out of the box, and those can be defined in the xml string resource, for instance

<string name="lorem_ipsum">
   This is <font color="red">red</font> and this
   <b><i>bold and italic (nested); </i>this just bold</b>,
   <u>underlined</u>
</string>

and resolved into a CharSequence by calling context.getText(stringRes: Int) which takes care of all the supported HTML tags to style the text without us needing to do anything else

However, sometimes we need to build a part of the string dynamically. In order to do this, we need to mark the dynamic parts in the xml with placeholders in the form of %{digit}${type}, for instance %1$s is a string passed as first vararg and %2$d is a decimal number passed as second vararg in String.format(text: String, vararg args: String), which is called to resolve the placeholders.

Nevertheless, things start getting complicated if we define HTML tags together with dynamic placeholders in xml string resources, for instance something like this

<string name="lorem_ipsum">
   This is <font color="red">red</font> and this
   <b><i>bold and italic (nested); </i>this just bold</b>, <u>underlined</u>
   and here the placeholder = %1s
</string>

If we take a look at the method signature of String.format(text: String, vararg args: String), its first argument requires a String instead of a CharSequence. This means, the dynamic text placeholders will be correctly replaced, but our CharSequence has to be converted to String, throwing away its styling.

In order to deal with HTML markup, Android provides HtmlCompat. It requires that the string resource encodes its opening unsafe characters, namely: '<' , which becomes '&lt;'

<string name="lorem_ipsum">
   This is &lt;font color="red">red&lt;/font> 
   and this &lt;b>&lt;i>bold and italic (nested); &lt;/i>this just bold&lt;/b>,
   &lt;u>underlined&lt;/u> and here the placeholder = %1s
</string>

or alternatively, we can wrap the resource inside CDATASections instead to the xml as follows:

<string name="lorem_ipsum">
<![CDATA[This is <font color="red">red</font> and this <b><i>bold and italic (nested); </i>this just bold</b>, <u>underlined</u> and here the placeholder = %1s]]>
</string>

In any case, given our dynamic placeholder text to be "placeholder1", we can get the expected result by using HtmlCompat as follows:

val text = context.getString(R.string.lorem_ipsum)
val dynamicText = String.format(text, "placeholder1") 
val dynamicStyledText = HtmlCompat.fromHtml(
      dynamicText,
      HtmlCompat.FROM_HTML_MODE_COMPACT
)

textView.text = dynamicStyledText

Although the code above seems to work robustly, the result differs if the dynamic placeholder text contains at least one unescaped HTML character, for instance:<, >, &, \ or ", like in <placeholder1>, leading to the result below

Yes, the placeholder just disappears. That's because characters must be escaped before calling HtmlCompat.fromHtml(). We solve that by encoding the placeholders before using HtmlCompat, like this

val text = context.getString(R.string.lorem_ipsum)
val encodedPlaceholder = TextUtils.htmlEncode("<placeholder1>")
val dynamicText = String.format(text, encodedPlaceholder) 
val dynamicStyledText = HtmlCompat.fromHtml(
      dynamicText,
      HtmlCompat.FROM_HTML_MODE_COMPACT
)

textView.text = dynamicStyledText

Although it works and it is the recommended way according to the official documentation, I personally do not like any of the approaches before. Why?

  1. You end up changing the xml string resource completely for the sake of using a dynamic text placeholder
  2. You lose xml highlighting in the styled parts of the string resource and therefore, it is harder to read

A better approach would be to create a method that can handle the original xml string resource with HTML tags and placeholders. In doing so, it does not matter whether the string resource contains HTML markup or not, the method simply handles the placeholders while keeping the style defined by the (existing, if any) HTML tags ... no need to either replace opening unsafe characters or add CDATASections.

And yes, it is possible with a bit of hackery. Let's see how.

Digging into a better solution

We already know that using context.getText(R.string.lorem_ipsum) returns the string resource styled as a CharSequence. If the string resource has a placeholder, it will be shown the same as in the xml.

We also know that HtmlCompat.fromHtml() processes "some" HTML tags. Its inverse method exists and does exactly the opposite: takes a Spanned object and converts it to a string with the corresponding HTML tags. The flag we pass to the method also matters: HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL also adds a new line at the end of the HTML string and we have to account for that. Therefore, we can get the desired HTML string as follows

// step 2 - toHtml()
val spannedString = SpannedString(styledString)
val htmlString = HtmlCompat.toHtml(
        spannedString,
        HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL
    )
        .substringBeforeLast('>')
        .plus(">")

which results into

We've got the equivalent HTML string to the styled String of the first step so far. However, the final goal is to replace its placeholders with the corresponding values. As you might remember, I mentioned at the beginning of the article that we can use String.format(text: String, vararg args: String) for that. It would not work with a CharSequence, but that is why we converted it into its equivalent HTML string in the first place.

// step 3 - String.format()
val dynamicHtmlString = String.format(htmlString, args)

Just convert the HTML text into a CharSequence and we get the desired style. Remember to use HtmlCompat.FROM_HTML_MODE_COMPACT, since it is the inverse of the HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL we've previously used

// step 4 - fromHtml()
val result = HtmlCompat.fromHtml(
    dynamicStyledString,
    HtmlCompat.FROM_HTML_MODE_COMPACT
)
.removeSuffix("\n")   // fromHtml() adds a new line at the end

Well we are almost done... as we have seen at the beginning of this article, if the placeholders are Strings containing unsafe characters, they do not show up. Therefore, do not forget that we need to encode the string values that will substitute the placeholders. A Kotlin extension function following all the aforementioned steps would look like this

fun Context.getHtmlStyledText(
    @StringRes htmlStringRes: Int,
    vararg args: Any
): CharSequence {

    // step 0 - Encode string placeholders  
    val escapedArgs = args.map {
        if (it is String) TextUtils.htmlEncode(it) else it
    }.toTypedArray()

    // step 1 - getText()
    val styledString = Context.getText(htmlStringRes)

    // step 2 - toHtml()
    val spannedString = SpannedString(styledString)
    val htmlString = HtmlCompat.toHtml(
        spannedString,
        HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL
    )
        .substringBeforeLast('>')
        .plus(">")

    // step 3 - String.format()
    val dynamicStyledString = String.format(htmlString, *escapedArgs)

    // step 4 - fromHtml()
    return HtmlCompat.fromHtml(
        dynamicStyledString,
        HtmlCompat.FROM_HTML_MODE_COMPACT
    )
    .removeSuffix("\n")   //fromHtml() adds one new line at the end
}

BONUS

The same idea applies to plural resources. Simply replace

// step 1 - getText()
val styledString = context.getText(R.string.lorem_ipsum)

with

// step 1 - getText()
val styledString = context.resources.getQuantityText(R.plural.lorem_ipsum, quantity)

You can find the corresponding working gist for strings and plurals here

Cover photo by Markus Spiske on Unsplash

18