Thuy's Blog

Handy TextUtils.expandTemplate()

September 10, 2017

Problem

Recently I have been struck by an interesting problem that, I want to handle clicks on some sub-strings inside a localized string. For example, have you seen this UI from Here WeGo app? As we can see, safety, why we collect data, Service Terms, Privacy Policy, and Learn more about Privacy are actually clickable. When we click them, each sub-string will lead to different content pages.

Here WeGo

Another example is from Airbnb app where there’s an UI showing a host name like below (e.g. Hosted by Yukari). Yukari is a clickable sub-string leading to a host profile page.

Airbnb

So, how do we build that kind of thing?

Solution

SpannableStringBuilder has its limitations

Let’s choose the case of Airbnb. One approach that came straight out of my mind is to use SpannableStringBuilder to concat "Hosted by " and a clickable SpannableString created from host name, like:

val text = SpannableStringBuilder()
    .append("Hosted by ")
    .append(createClickableSpannableString(hostName))

This seems to work right? Yeah, for English and some languages having the same grammar as English. If your app supports multi-languages, this isn’t gonna be a solution because some other languages can have host name going before the verb Hosted. For example, "Hosted by Yukari" can be translated into Vietnamese as "Yukari làm chủ"

So, let’s try another way. How about?

  • First, provide a string, for instance, "Hosted by %s" which can be translated.
  • Then, create a string formatted (e.g. via String.format()) from that string and a variable of host name as an argument.
  • Next, use pattern matching to figure out which sub-string is the host name. The result we got is a start index. Then, put the formatted string into a SpannableString. Use SpannableString.setSpan() that takes start index and the length of host name to make the found sub-string clickable via ClickableSpan.
val startIndexOfHostName = getStartIndexOfHostName(formattedString, hostName)
val spannable = SpannableString(formattedString)
spannable.setSpan(object : ClickableSpan() {
  override fun onClick(widget: View?) = showHostProfile()

  override fun updateDrawState(ds: TextPaint?) {
    super.updateDrawState(ds)
    ds?.color = Color.BLACK
  }
}, startIndexOfHostName, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)

This should work. However, in many other cases, what if the sub-string has more than one occurrence? For example, consider "A dog is a kind of animal. This is an image of a dog." where the second occurrence of dog should be made clickable. So you have to perform a more complex pattern matching right? What if we don’t need to do that?

TextUtils.expandTemplate() is a better choice

There’s an alternative approach which utilizes TextUtils.expandTemplate(). The docs for TextUtils.expandTemplate() explains pretty well how it works already. So, basically, back to the example of the Here WeGo app, the idea is that I would provide following strings.

<string name="termsOfService">Find out about %1$s when using this app and learn %2$s. Tap \'Go!\' to agree to the %3$s and %4$s. %5$s.</string>
<string name="safety">safety</string>
<string name="whyWeCollectData">why we collect data</string>
<string name="serviceTerms">Service Terms</string>
<string name="privacyPolicy">Privacy Policy</string>
<string name="learnMoreAboutPrivacy">Learn more about Privacy</string>

I used %1$s, %2$s, and so on to showcase that, maybe, those arguments are often generated automatically by your string management system. But the argument format that TextUtils.expandTemplate() can understand is rather ^1, ^2, and so on. So if that’s a real case, I need to convert %1$s and %2$s to ^1 and ^2 respectively.

val template = getString(R.string.termsOfService)
    .replace("%1\$s", "^1")
    .replace("%2\$s", "^2")
    .replace("%3\$s", "^3")
    .replace("%4\$s", "^4")
    .replace("%5\$s", "^5")

Next, I’ll write an extension function to convert sub-strings to SpannableString.

fun CharSequence.asClickable(whenClick: (CharSequence) -> Unit): SpannableString {
  val spannable = SpannableString(this)
  spannable.setSpan(object : ClickableSpan() {
    override fun onClick(widget: View?) = whenClick(this@asClickable)

    override fun updateDrawState(ds: TextPaint?) {
      super.updateDrawState(ds)
      ds?.color = Color.BLACK
    }
  }, 0, length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
  return spannable
}

Then, let TextUtils.expandTemplate() do the magic:

val template = getString(R.string.termsOfService)
    .replace("%1\$s", "^1")
    .replace("%2\$s", "^2")
    .replace("%3\$s", "^3")
    .replace("%4\$s", "^4")
    .replace("%5\$s", "^5")
termsOfServiceView.text = TextUtils.expandTemplate(
    template,
    getString(R.string.safety).asClickable { it.toast() },
    getString(R.string.whyWeCollectData).asClickable { it.toast() },
    getString(R.string.serviceTerms).asClickable { it.toast() },
    getString(R.string.privacyPolicy).asClickable { it.toast() },
    getString(R.string.learnMoreAboutPrivacy).asClickable { it.toast() }
)

// To make the links actually clickable.
termsOfServiceView.movementMethod = LinkMovementMethod.getInstance()

You may wonder what toast() does. It just toasts the clicked sub-string so that we can recognize which sub-string we clicked on.

private fun CharSequence.toast() {
  Toast.makeText(applicationContext, this@toast, Toast.LENGTH_SHORT).show()
}

However, this approach won’t work if the number of arguments is more than 9, because TextUtils.expandTemplate() will throw exception in that case.

Final result:

Final result

You can fork the demo at here.


Thuy Trinh

Written by Thuy Trinh who lives and works in Frankfurt, Germany building robust Android apps. You should follow him on Twitter