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.
Building dynamic Strings in xml
The official approach
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
Problems with the official approach
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.
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.formatt()
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 = 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. You need to add a quantity
parameter to the method and change the step 1 as follows:
instead of
// step 1 - getText()
val styledString = getText(R.string.lorem_ipsum)
do
// step 1 - getText()
val styledString = resources.getQuantityText(R.plural.lorem_ipsum, quantity)
You can find the corresponding working gist for strings and plurals here
Contact and further blog posts
If you like this post you can follow me on Twitter or may also like to read my posts in the series on
Snapshot testing
- An introduction to snapshot testing on Android in 2021
- The secrets of effectively snapshot testing on Android
- UI tests vs. snapshot tests on Android: which one should I write? ๐ค
- Design a pixel perfect Android app ๐จ
or
Multiplying the quality of your Unit Tests
Cover photo by Markus Spiske on Unsplash