Working with spans in Android

Or how to do it nicely

Spans and Spannables are the great feature in Android SDK. They help programmers to create a complex CharSequences with different text sizes, colors, typefaces and also attach links to text. Even text with image in it is possible with Spannable.

One of the most used cases for spannables is UrlSpan

Spannable’s api is pretty nasty. I agree, that it gives you big amount of freedom. But come one. Just for example above we need to implement next code:

<string name="temrs_of_service">
<![CDATA[By continuing, you agree to the <a href="terms_of_service_url">Terms of Service</a> and <a href="privacy_policy_url">Privacy Policy</a>.]]>
</string>

Ok, in xml it looks pretty simple. But it is just an example. In real life there are tasks, when you need to paint your link, or maybe change size of it. Also, there are cases, when you have template string, but other info you receive from server. So let’s see, how it will look in the code:

val termsOfService = context.getString(R.string.terms_of_service)
val privacyPolicy = context.getString(R.string.privacy_policy)

val phrase = context.getString(R.string.terms, termsOfService, privacyPolicy)
val termsOfServicesStart = phrase.indexOf(termsOfService)
val privacyPolicyStart = phrase.indexOf(privacyPolicy)
val spannable = SpannableStringBuilder(phrase)
spannable.setSpan(URLSpan("terms_of_service_url"), termsOfServicesStart, termsOfServicesStart + termsOfService.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)spannable.setSpan(URLSpan("privacy_policy_url"), privacyPolicyStart, privacyPolicyStart + privacyPolicy.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)

There it is. Cumbersome amount of code that doing almost what we need. But either this one, or example above will show:

Pretty same, except underline on the links, but it is a trifle. More important thing, that code, written above could be written like that:

context.resources.getSpannable(R.string.terms,
context.getString(R.string.terms_of_service) to listOf(URLSpan("terms_of_service_url")),
context.getString(R.string.privacy_policy) to listOf(URLSpan("privacy_policy_url")))

I think you agree, that this is more concise form.

Other example, when you want to construct some complex string from code. You need to build it from parts, and each of them should have it’s own size, color and function.

For example we want to have next text:

In ordinary case we will write something like:

val firstSent = "This is the first sentence."
val
secondSent = "This is the second sentence."
val
thirdSent = "This is the third sentence."

var
index = 0
val spannable = SpannableStringBuilder()
spannable.append(firstSent)
spannable.setSpan(clickableSpan { context.showToast("clicked on first") }, index, index + firstSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, R.color.colorPrimary)), index, index + firstSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(AbsoluteSizeSpan(context.resources.getDimensionPixelSize(R.dimen.first_size)), index, index + firstSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.append("\n")

index = spannable.length
spannable.append(secondSent)
spannable.setSpan(clickableSpan { context.showToast("clicked on second") }, index, index + secondSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, R.color.colorPrimaryDark)), index, index + secondSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(AbsoluteSizeSpan(context.resources.getDimensionPixelSize(R.dimen.second_size)), index, index + secondSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.append("\n")

index = spannable.length
spannable.append(thirdSent)
spannable.setSpan(clickableSpan { context.showToast("clicked on third") }, index, index + thirdSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(ContextCompat.getColor(context, R.color.colorAccent)), index, index + thirdSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannable.setSpan(AbsoluteSizeSpan(context.resources.getDimensionPixelSize(R.dimen.third_size)), index, index + thirdSent.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)

return spannable

Okey, okey. Let’s make some refactoring according to DRY principle:

val firstSent = "This is the first sentence."
val
secondSent = "This is the second sentence."
val
thirdSent = "This is the third sentence."

val
spannable = SpannableStringBuilder()
spannable.append(context, firstSent, R.color.colorPrimary, R.dimen.first_size) { context.showToast("clicked on first") }
spannable.append(context, secondSent, R.color.colorPrimaryDark, R.dimen.second_size) { context.showToast("clicked on second") }
spannable.append(context, thirdSent, R.color.colorAccent, R.dimen.third_size) { context.showToast("clicked on third") }
return
spannable

Where SpannableStringBuilder.append is:

fun SpannableStringBuilder.append(
context: Context,
text: CharSequence,
@ColorRes textColorRes: Int,
@DimenRes textSizeRes: Int,
clickAction: () -> Unit) {
val index = length
append(text)
setSpan(clickableSpan(clickAction), index, index + text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
setSpan(ForegroundColorSpan(ContextCompat.getColor(context, textColorRes)), index, index + text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
setSpan(AbsoluteSizeSpan(context.resources.getDimensionPixelSize(textSizeRes)), index, index + text.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}

But still it is pretty verbose. Isn’t it better to write:

return SpannableStringCreator()
.append("This is the first sentence.", context.resSpans {
color(R.color.colorPrimary)
size(R.dimen.first_size)
click { context.showToast("clicked on first") }
})
.appendLn("This is the second sentence.", context.resSpans {
color(R.color.colorPrimaryDark)
size(R.dimen.second_size)
click { context.showToast("clicked on second") }
})
.appendLn("This is the third sentence.", context.resSpans {
color(R.color.colorAccent)
size(R.dimen.third_size)
click { context.showToast("clicked on third") }
})
.toSpannableString()

Solution

To make work with spannable far more easy that right now, we need to implement only 3 classes, 3 extensions and anonymous class.

The core of our solution is special string builder, SpannableStringCreator:

Creator is responsible only for building a spannable string, so it provides pack of append-methods for doing it nicely. With this creator we able to construct our spannable string part by part and also, assign special spans for each part.

To reduce verbosity of creating spans (for example, instead of ForegroundColorSpan(ContextCompat.getColor(context, textColorRes)) we would able to write color(textColorRes)) special span wrapper is introduced:

Also, we adding handy small context extension

inline fun Context.resSpans(options: ResSpans.() -> Unit) =
ResSpans(this).apply(options)

Now we able to implement aforementioned Example 2 with only those 2 classes. But the question, how to get string from resources with placeholders and apply spans for those placeholders, remains untouched.

To reply to it, we need to introduce our own Appendable implementation that calls SpannableAppendable:

This class will serves as special interceptor of string formatting, so we will be able to tackle every added placeholder and apply spans to it.

Now all we need is to write couple of extensions:

fun Resources.getSpannable(@StringRes id: Int, vararg spanParts: Pair<Any, Iterable<Any>>): CharSequence {
val resultCreator = SpannableStringCreator()
Formatter(
SpannableAppendable(resultCreator, *spanParts),
configuration.locale)
.format(getString(id), *spanParts.map { it.first }.toTypedArray())
return resultCreator.toSpannableString()
}
fun Resources.getText(@StringRes id: Int, vararg formatArgs: Any?) =
getSpannable(id, *formatArgs.filterNotNull().map { it to emptyList<Any>() }.toTypedArray())

First extension will help you to achieve Example 1 and second one solves significant (at least it was one time for me) problem. When you want to retrieve text from xml as CharSequence not a String, you need to use getText method from Resources class, not getString, but it doesn’t have implementation with placeholders. Now it has. :)

Afterwords

Api is very important things, since depending on how comfortable you with it, the development speed changes.

Love being creative to solve some problems with an simple and elegant ways