Kotlin-powered Spans: Enhancing Readability and Usability in Android

Oğuzhan Aslan
6 min readJun 10, 2023
Photo by Robert Anasch on Unsplash

No wonder how commonly we use TextViews in Android ( in XML projects, of course), and it is also so common to style them with colors, fonts and sizes, etc. We can achieve this by using styles and themes, as we know. But what if you want to change only part of the text in a TextView? For example, you want to change the color of the sub-string “Hello” in the text “Hello World” to red and the color of the sub-string “World” to blue.

This is where SpannableString comes in handy. It allows you to style only part of the text by using spans and not just colors, but also sizes, fonts, and even click events. Spans and SpannableString are not new to Android. They have been around since API Level 1. Moreover, for the sake of simplicity, I will not go into details about them, but you can read more about them here. In this article, I talk about issues I faced when using spans and show you a simple solution to them.

The Problem

The Issues I have been facing with SpannableString are:

- boilerplate code

- hard to read

- managing span indices (especially across multi-language texts)

Let me explain them in more detail. As you may know, the span library is written in Java, so it contains java features and does not take advantage of Kotlin features (by default). I do not say it is garbage, but I think it can be better. It can be improved with Kotlin features. For instance, if you want to create a click listener for a span or a part of your text in the textView you use, You will need to write something like this :

val spannable = SpannableString("")
spannable.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
// do something
}
},
/*start index*/,
/*end index*/,
SpannableString.SPAN_EXCLUSIVE_INCLUSIVE
)

This code works fine, but it requires the exact indexes of the span you want to set the click listener for. That becomes a problem when your app is multi-language since you will need to calculate the indexes for each language. Apart from the fact that it requires effort any time you want to add a new language, it is also easy to forget to add the indexes for a new language. Moreover, I have written about kotlin SAM feature and its advantages in one of my past articles (you can read it here). And, it is clear that the code above can be improved with Kotlin SAM feature.

Another issue is that it is hard to read. The code you are writing becomes too long and hard to read too quickly due to the library’s nature. This is especially true when you want to set multiple spans for a single text. For example, if you want to set a click listener for a span and change the color of another span, you will need to write something like this :

val spannable = SpannableString("")
spannable.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
// do something
}
},
/*start index*/,
/*end index*/,
SpannableString.SPAN_EXCLUSIVE_INCLUSIVE
)
spannable.setSpan(
ForegroundColorSpan(Color.RED),
/*start index*/,
/*end index*/,
SpannableString.SPAN_EXCLUSIVE_INCLUSIVE
)

Just imagine a case where you want to underline, change color, and set a click listener for a span. It will be a nightmare to read and remember you need to keep track of the indexes for each span. I think things got a little bit messy for a simple task like this.

The Solution

Jetpack compose, also, has text spanning feature which simply solves all the issues I mentioned above. lets examine how it works without getting into details.

Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(Color.Blue)) {
append("Hello")
}
append(" World")
},
modifier = Modifier.clickable {
// do something
}
)

As you can see, there is no indices to maintain, no boilerplate code, and it is so easy to read. And also no need to remember span classes like ForegroundColorSpan, ClickableSpan, etc. You can simply use SpanStyle class and set the color, font, size, etc.

Of course, you can use compose’s text spanning feature in your xml projects too. But you need to add compose to your project. But we can use a similar approach to compose’s text spanning feature in our xml projects without adding compose to our project. Let’s see how we can do that.

The Implementation

I want to add kotlin DSL feature to spanning and acheive something like this :

textView.span(/* substring to span */) {
onClick {/* do something */}
foreground(/* color */)
underline()
background( /* color */)
insert("insertText", /* index */)
}

which is simply takes a substring to span and lambda function to modify spanning options.

First of all, we need to define the extension function called `span` for TextView class.

fun TextView.span(
spanSubString: String,
builderBlock: SpanBuilder.() -> Unit,
) {
if(spanSubString.isEmpty()) {
Log.d(TAG, "span: Spannable string cannot be empty")
return
}
if(text.isEmpty()) {
Log.d(TAG, "span: Text cannot be empty")
return
}
if(!text.contains(spanSubString)) {
Log.d(TAG, "span: Text must contain the spannable string")
return
}
val spannableString = SpannableString(text)
val textOfView = text.toString()
val rangeOfSpanText = textOfView.indexRangeOf(spanSubString)
rangeOfSpanText?.let {
val spanBuilder = SpanBuilder(textView = this)
spanBuilder.range = rangeOfSpanText
spanBuilder.spannableString = spannableString
spanBuilder.builderBlock()
text = spannableString
}
}

Here, we need to check if the text of the textView contains the spannable string. If it does not, we return, you may modify this part to throw an exception. Also, `indexRangeOf` function returns start and end index pair of the substring in the text.

fun String.indexRangeOf(sub: String): Pair<Int, Int>? {
val start = indexOf(sub)
return when (start != -1) {
true -> Pair(start, start + sub.length - 1)
false -> null
}
}

Furthermore, we need to define the SpanBuilder class which is responsible for modifying the spannable string.

class SpanBuilder(
private val textView: TextView
) {
internal var range: Pair<Int, Int>? = null
internal var spannableString: SpannableString? = null

fun foreground(
@ColorInt color: Int,
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
) {
range?.let {
spannableString?.setSpan(
ForegroundColorSpan(color),
it.first,
it.second + 1,
flag
)
}
}

fun background(
@ColorInt color: Int,
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
) {
range?.let {
spannableString?.setSpan(
BackgroundColorSpan(color),
it.first,
it.second + 1,
flag
)
}
}

fun underline(
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
) {
range?.let {
spannableString?.setSpan(
UnderlineSpan(),
it.first,
it.second + 1,
flag
)
}
}

fun bulletSpan(
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
gapInPx: Int = BulletSpan.STANDARD_GAP_WIDTH,
@ColorInt color : Int = Color.BLACK
) {
range?.let {
spannableString?.setSpan(
BulletSpan(gapInPx,color),
it.first,
it.second + 1,
flag
)
}
}

fun bulletSpan(
subString: String,
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
gapInPx: Int = BulletSpan.STANDARD_GAP_WIDTH,
@ColorInt color : Int = Color.BLACK
) {
val range = textView.text.toString().indexRangeOf(subString)
range?.let {
spannableString?.setSpan(
BulletSpan(gapInPx,color),
it.first,
it.second + 1,
flag
)
}
}

fun insert(
text : String,
index : Int
) {
val spannableStringBuilder = SpannableStringBuilder(textView.text)
spannableStringBuilder.insert(index,text)
}

fun bold(
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
) {
range?.let {
spannableString?.setSpan(
StyleSpan(android.graphics.Typeface.BOLD),
it.first,
it.second + 1,
flag
)
}
}

fun onClick(
flag: Int = SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE,
onClick: (View) -> Unit,
) {
range?.let {
spannableString?.setSpan(
object : ClickableSpan() {
override fun onClick(p0: View) {
onClick(p0)
}
},
it.first,
it.second + 1,
flag
)
}
}
}

Above, SpanBuilder class sets spans to the spannable string to calculated range. by using this class, we no longer need to remember exact span class to apply required span. It is just a simple function call. Additionally, span index range is calculated automatically. So, we no longer need to maintain indices.

Finally, we can use this extension function like this :

textView.span("spannable") {
onClick {
Toast.makeText(textView.context, "spannable clicked", Toast.LENGTH_SHORT).show()
}
foreground(Color.RED)
underline()
background(Color.YELLOW)
}

Bonus

If you want to span whole text of a textView, you can write such a overload function.

fun TextView.span(
builderBlock: SpanBuilder.() -> Unit,
) {
val textOfView = text.toString()
this.span(textOfView, builderBlock)
}

Conclusion

I hope you find this article useful. You can find the source code of this article here.

https://gist.github.com/oguzhanaslann/39ac005aa177514b869ec252aa261791

LinkedIn

Love you all.

Stay tune for upcoming blogs.

Take care.

--

--