18
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 '<'
<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>
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?
- You end up changing the xml string resource completely for the sake of using a dynamic text placeholder
- 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.
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
}
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