Adetunji Dahunsi

Easy text styling and concatenation in Android

What if styling spans in Android was fluent and joining them as simple String concatenation?

TJ Dahunsi

Jan 11 2020 · 5 min read

Categories:
android
kotlin

Spansand Spannables are the fundamental abstraction for text styling and markup in Android. A thorough breakdown of how they work can be seen in detail here: Spantastic text styling with Spans *To style text in Android, use spans! Change the color of a few characters, make them clickable, scale the size of the…*medium.com

The issue with spans in Android however is how difficult they can be to create. The SpannableStringBuilder class helps with this significantly, especially when combined with the available KTX extensions. It still remains tedious to work with however, as each bit of text that needs to be styled, still has to have a span appended to it, before adding it to the SpannableStringBuilder. This typically goes something like this:

1val string = buildSpannableString { 2 append("no styling text") 3 bold { 4 append("bold") 5 italic { append("bold and italic") } 6 } 7 inSpans(RelativeSizeSpan(2f), QuoteSpan()) { 8 append("double sized quote text") 9 } 10}

While this DSL-like API is much better than what we had before, it’s still a bit cumbersome and verbose. It’s nowhere quite as natural or simple String concatenation or using theString.format()method. Ideally, we should be able to create styled spans and join them arbitrarily in the ways that are most natural, i.e, like they were ordinary Strings.

First, let’s start with creating arbitrary styled spans from simple Strings. A suitable point of abstraction for this is the CharSequence interface; using Kotlin extensions, we can create spanned CharSequence instances from raw Strings by marking them up with spans via a SpannableStringBuilder. This is done by first creating an empty SpannableStringBuilder, opening Spannable tags on it with the Spanned.SPAN_MARK_MARK constant, appending the bit of text that needs markup, then finally closing the tags with the Spanned.SPAN_EXCLUSIVE_EXCLUSIVE constant.

1 2/** 3 * Applies a list of zero or more tags to the entire range in the CharSequence. 4 * 5 * @param tags the styled span objects to apply to the content 6 * such as android.text.style.StyleSpan 7 */ 8private fun CharSequence.applyTags(vararg tags: Any): CharSequence { 9 val text = SpannableStringBuilder() 10 11 openTags(text, tags) 12 13 text.append(this) 14 15 closeTags(text, tags) 16 return text 17} 18 19/** 20 * Iterates over an array of tags and applies them to the beginning of the specified 21 * Spannable object so that future text appended to the text will have the styling 22 * applied to it. Do not call this method directly. 23 */ 24private fun openTags(text: Spannable, tags: Array<out Any>) { 25 for (tag in tags) text.setSpan(tag, 0, 0, Spannable.SPAN_MARK_MARK) 26} 27 28/** 29 * "Closes" the specified tags on a Spannable by updating the spans to be 30 * endpoint-exclusive so that future text appended to the end will not take 31 * on the same styling. Do not call this method directly. 32 */ 33private fun closeTags(text: Spannable, tags: Array<out Any>) { 34 val len = text.length 35 for (tag in tags) 36 if (len > 0) text.setSpan(tag, 0, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 37 else text.removeSpan(tag) 38}

In the snippet above, an arbitrary amount of tags (spans) can be applied to a CharSequence, and a SpannableStringBuilder is returned with them applied. When combined with the many implementations of spans in the android.text.style.* package, applying styles to text can be as easy as the following:

1fun CharSequence.appendNewLine() = CONCATENATE_FORMATTER.formatSpanned(this, NEW_LINE) 2 3fun <T : CharacterStyle> CharSequence.applyStyles(vararg styles: T) = this.applyTags(styles) 4 5fun CharSequence.bold() = applyStyles(StyleSpan(Typeface.BOLD)) 6 7fun CharSequence.italic() = applyStyles(StyleSpan(Typeface.ITALIC)) 8 9fun CharSequence.underline() = applyStyles(UnderlineSpan()) 10 11fun CharSequence.scale(relativeSize: Float) = applyStyles(RelativeSizeSpan(relativeSize)) 12 13fun CharSequence.scaleX(relativeSize: Float) = applyStyles(ScaleXSpan(relativeSize)) 14 15fun CharSequence.backgroundColor(@ColorInt color: Int) = applyStyles(BackgroundColorSpan(color)) 16 17fun CharSequence.strikeThrough() = applyStyles(StrikethroughSpan()) 18 19fun CharSequence.superScript() = applyStyles(SuperscriptSpan()) 20 21fun CharSequence.subScript() = applyStyles(SubscriptSpan()) 22 23fun CharSequence.color(@ColorInt color: Int) = applyStyles(ForegroundColorSpan(color)) 24 25fun CharSequence.shiftBaseline(ratio: Float) = applyStyles(BaselineShiftSpan(ratio)) 26 27fun CharSequence.click(paintConsumer: (TextPaint) -> Unit = {}, clickAction: () -> Unit) = this.applyTags(object : ClickableSpan() { 28 override fun onClick(widget: View) = clickAction.invoke() 29 30 override fun updateDrawState(paint: TextPaint) = paintConsumer.invoke(paint) 31})

The above can also be chained to create a fluent API, so bolding, italicizing, and underlining a raw String can be:

1"Hi! I am bold, italicized and underlined" 2.bold().italic().underline()

Which will create a new SpannableStringBuilder for each invocation, and apply the specified style. If you want to reduce the number of SpannableStringBuilders created, you could apply the spans yourself by calling:

1"Hi! I am bold, italicized and underlined".applyStyles( 2StyleSpan(Typeface.*BOLD*), 3StyleSpan(Typeface.*ITALIC*), 4UnderlineSpan() 5)

Losing a bit of fluency for some efficiency, which is a sensible tradeoff to make at your discretion. Also, if you created a custom Span, the above is the route to adding it to the fluent API via your own extension method.

Output of the code snippets above Output of the code snippets above

With that done, the next step is creating a version of String.format() that preserves styles that are encoded in whatever spans of the format arguments.

1private val FORMAT_SEQUENCE = Pattern.compile("%([0-9]+\\$|<?)([^a-zA-z%]*)([[a-zA-Z%]&&[^tT]]|[tT][a-zA-Z])") 2 3/** 4 * Version of [String.format] that works on [Spanned] strings to preserve rich text formatting. 5 * Both the `format` as well as any `%s args` can be Spanned and will have their formatting preserved. 6 * Due to the way [Spannable]s work, any argument's spans will can only be included **once** in the result. 7 * Any duplicates will appear as text only. 8 * 9 * @param args the list of arguments passed to the formatter. If there are 10 * more arguments than required by `format`, 11 * additional arguments are ignored. 12 * @see [java.util.Formatter.format] 13 * @return the formatted string (with spans). 14 */ 15fun CharSequence.formatSpanned(vararg args: Any): SpannableStringBuilder = 16 formatActual(Locale.getDefault(), this, *args) 17 18private fun formatActual(locale: Locale, format: CharSequence, vararg args: Any): SpannableStringBuilder { 19 val out = SpannableStringBuilder(format) 20 21 var start = 0 22 var argAt = -1 23 24 while (start < out.length) { 25 val matcher = FORMAT_SEQUENCE.matcher(out) 26 if (!matcher.find(start)) break 27 28 start = matcher.start() 29 val exprEnd = matcher.end() 30 31 val argTerm = matcher.group(1) 32 val modTerm = matcher.group(2) 33 val typeTerm = matcher.group(3) 34 35 val cookedArg: CharSequence = when (typeTerm) { 36 "%" -> "%" 37 "n" -> "\n" 38 else -> { 39 val argIdx: Int = when (argTerm) { 40 "" -> ++argAt 41 "<" -> argAt 42 else -> Integer.parseInt(argTerm!!.substring(0, argTerm.length - 1)) - 1 43 } 44 45 val argItem = args[argIdx] 46 47 if (typeTerm == "s" && argItem is Spanned) argItem 48 else String.format(locale, "%$modTerm$typeTerm", argItem) 49 } 50 } 51 52 out.replace(start, exprEnd, cookedArg) 53 start += cookedArg.length 54 } 55 56 return out 57}

CharSequence formatting that preserves styling

The above makes some common Android tasks easier. Terms and conditions in your login flow?

1"Please accept the %1\$s and %2\$s before continuing".formatSpanned( 2 "terms".underline().click { */*go to link*/ *}, 3 "conditions".underline().click { */*go to link*/ *} 4)

Where the actual string values in the above should be gotten from resources to allow for easy internationalization.

Easy terms and conditions span creation Easy terms and conditions span creation

Finally, to round out the API, an operator plus overload for concatenating styledCharSequences can be done. Given spanned Strings A and B, concatenating them would be the application of the format replacements to “%1\$s%2\$s”, where the first and second arguments are A and B respectively.

1private const val CONCATENATE_FORMATTER = "%1\$s%2\$s" 2 3operator fun CharSequence.plus(other: CharSequence) = CONCATENATE_FORMATTER.formatSpanned(this, other) 4

Operator overloading allowing for CharSequence concatenation

A decidedly contrived example of which would resemble something like this:

1"This".bold() + 2" last " + 3"paragraph".italic() + 4" is a " + 5"flex".underline() + 6" to show the " + 7"plus".bold().italic().color(color).scale(1.8f) + 8" operator overload".color(color)

Appending spans easily by concatenating themAppending spans easily by concatenating them

All the extensions above can be found in the repo below along with a sample app. If you’d like to try it out, the dependency can be found at

implementation ‘com.tunjid.androidx:core:1.1.0’

The full source can be found at:

3