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
Spans
and 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
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
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 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:
18