You probably used apps which have a TextView with a link to a Terms of Service page, or Privacy Notes, or Help Center, or anything else that is available on web but isn’t built into the app.

Google Chrome

Shopify Point of Sale

Ever wondered how it’s built? The short answer is Spans .

There won’t be any theory in this article since there’s plenty of good resources out there like this one:

Let’s start with an example that just sets plain unformatted text into a text view:

textView.text = resources.getString(R.string.need_help_text)

And the text in strings.xml file will look like:

<string name="need_help_text">Need help?

Visit the Help Center.</string>

Let’s say we will make “Help Center” part of the phrase a link.

In order to do that we’d need to create a SpannableString and set it as text view’s text. Just like that:

val fullText = getText(R.string.need_help_text) as SpannedString

val spannableString = SpannableString(fullText)

// ????

textView.text = spannableString

SpannableString is the object we need set spans to. Generally, for any span we’d need to find a start position and end position, which could be accomplished using different approaches.

First, position could simply be hardcoded, i.e. “H” from “Help Center” that we want to be highlighted is 22 character in string and last letter “r” is 30.

ForegroundColorSpan(

ContextCompat.getColor(this@MainActivity, R.color.colorAccent)),

22,

32,

0

)

This is the weakest approach out of all because it’ll break every time you change the text.

Another option would be to find a position of “Help Center” in the text using indexOf or lastIndexOf functions (or any other alternatives you prefer). This approach is more solid, but if you add more than one language, you’d need to maintain the “indexOf-logic” very carefully. One typo will break all the things (which is exactly what happened to me).

Finally, the better approach over the last two is to use <annotation> . This tag basically defines the <key, value> pair. Then we can just retrieve those pairs as and array of spans like that:

val annotations = fullText.getSpans(0, fullText.length, Annotation::class.java)

And further configure each span individually using their keys and values.

In the full example here, we’d create annotation with key type and value help_link.

<string name="need_help_text">Need help?

Visit the <annotation type="help_link">Help Center.</annotation></string>

And finally will find the one with value help_link and create ClickableSpan and ForegroundColorSpan . First one will add click listener to piece of text, and second one will colour it. The nicest part here is that each span that comes from annotations already know its position in the text and we can get that position using getSpanStart and getSpanEnd . Full code snippet:

val fullText = getText(R.string.need_help_text) as SpannedString

val spannableString = SpannableString(fullText)



val annotations = fullText.getSpans(0, fullText.length, Annotation::class.java)



val clickableSpan = object : ClickableSpan() {

override fun onClick(widget: View?) {

Snackbar.make(window.decorView.rootView, "URL is clicked", LENGTH_SHORT).show()

}



override fun updateDrawState(ds: TextPaint?) {

ds?.isUnderlineText = false

}

}

annotations?.find { it.value == "help_link" }?.let {

spannableString.apply {

setSpan(

clickableSpan,

fullText.getSpanStart(it),

fullText.getSpanEnd(it),

Spanned.SPAN_EXCLUSIVE_EXCLUSIVE

)

setSpan(ForegroundColorSpan(

ContextCompat.getColor(this@MainActivity, R.color.colorAccent)),

fullText.getSpanStart(it),

fullText.getSpanEnd(it),

0

)

}

}



textView.apply {

text = spannableString

movementMethod = LinkMovementMethod.getInstance()

}

Note: don’t forget to set movementMethod = LinkMovementMethod.getInstance() on the textView so ClickableSpan works.

Find the full app sample on my Github:

Update: thanks to u/Tolriq for suggestion:

// LinkMovementMethod.getInstance() this method can crash when the // user have no activity to handle the links. This is a fix:

object SecureLinkMovementMethod : LinkMovementMethod() {



override fun onTouchEvent(widget: TextView?, buffer: Spannable?, event: MotionEvent?): Boolean {

return try {

super.onTouchEvent(widget, buffer, event)

} catch (ex: Exception) {

true

}

}

}

Thanks for spending your time reading it and let me know if I’m wrong somewhere or if there’s something that could do differently or better. I’m open to your feedback 🙌🏻