Android rich text: Overview

Cuong Le
7 min readJan 16, 2018

Why do we need rich text?

While working on UI tasks, we may often encounter some complex texts with various fonts, colors, styles displayed inline. Our first reaction to this is use multiple TextViews to achieve — each TextView sets different styles. We end up adding more weights to the view hierarchy. It becomes challenging when trying to render rich content which contains complicated views — a lot of text and images in RecyclerView. Fortunately, Android has fairly extensive support for formatted text lying in those packages android.text. *; android.text.style. *However, some of this rich text support has been shrouded in mystery. This article will explain how the rich text support in Android and how you can leverage it.

You may have seen some Android APIs that return CharSequence, Spanned, SpannableString, SpannedString… Is it confusing enough?

Let’s zoom out to a big picture

Technically speaking, these classes are used instead of the regular String. They contain inline markup rules. These rules indicate how text should be rendered such as bold, italic, foreground, background color, font size, font style…

Implementations

Spanned: An interface marker for text that has markup objects attached to ranges of it.

Spannable: An interface for text to which markup objects can be attached and detached

SpannedString: A class for text whose content and markup are immutable. It means, you cannot change either the text or the formatting of a SpannedString.

SpannableString: A class for text whose content is immutable but to which markup objects can be attached and detached. Compared to its immutable counterpart, SpannableString only allows to modify its markup rules via setSpan() .

SpannableStringInternal: An implementation markup logic for both SpannableString and SpannedString

Editable: An interface for text whose content and markup can be changed.

SpannableStringBuilder: A class acting as a Builder implements both Editable and Spannable, allows to modify text and formatting at the same time.

In some respects, we can deem SpannableString like a String. SpannableStringBuilder works like StringBuilder since it can splices ​​multiple String through append() method.

val word = SpannableString("The quick fox jumps over the lazy dog")

val builder = SpannableStringBuilder()
builder.append("The Quick Fox")
builder.append("jumps over")
builder.append("the lazy dog")

After Spannables are created, now we can setSpan() to achieve desired effects. Take a look at what Android provided us

void setSpan (Object what, int start, int end, int flags)

flags

Example 1

val builder = SpannableStringBuilder()
val spanned1 = SpannableString("Hello")
spanned1.setSpan(BackgroundColorSpan(Color.MAGENTA), 0, spanned1.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
val spanned2 = SpannableString("World")
textView.text = builder.append(spanned1).append(spanned2)

The effect is applied only for Hello because we exclude the previous and following characters around the word.

Example 2

val builder = SpannableStringBuilder()
val spanned1 = SpannableString("Hello")
spanned1.setSpan(BackgroundColorSpan(Color.MAGENTA), 0, spanned1.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
val spanned2 = SpannableString("World")
textView.text = builder.append(spanned1).append(spanned2)

The texts after Hello will also be affected by the flag SPAN_EXCLUSIVE_INCLUSIVE

Example 3

val builder = SpannableStringBuilder()
val spanned1 = SpannableString("Hello")
spanned1.setSpan(BackgroundColorSpan(Color.MAGENTA), 0, spanned1.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
val spanned2 = SpannableString("World")
spanned2.setSpan(BackgroundColorSpan(Color.GREEN), 0, 3, Spanned.SPAN_EXCLUSIVE_INCLUSIVE)
textView.text = builder.append(spanned1).append(spanned2)

Markup object of World will override spanned1 property with a length of 3.

A deeper look

private String mText;
private Object[] mSpans;
private int[] mSpanData;
private int mSpanCount;

private static final int START = 0;
private static final int END = 1;
private static final int FLAGS = 2;
private static final int COLUMNS = 3;

In its declaration, there are two internal arrays —a mSpans contains markup objects, meanwhile the other mSpanData holds markup rules matching to a style in mSpans array. The mSpanData array is depicted as an visual below

The mSpanData groups three variables start, end, flags. The array can be accessed by the offset corresponding to the property.

void setSpan(Object what, int start, int end, int flags) {
...
mSpans[mSpanCount] = what;
mSpanData[mSpanCount * COLUMNS + START] = start;
mSpanData[mSpanCount * COLUMNS + END] = end;
mSpanData[mSpanCount * COLUMNS + FLAGS] = flags;
mSpanCount++;
}

Span style

Span is divided into 4 major groups — CharacterStyle, ParagraphStyle, UpdateAppearance, UpdateLayout

  • A Span inherits from CharacterStyle/ParagraphStyle affects character-level/paragraph-level format.
  • A Span whose super class is UpdateAppearance should only modify text appearance. If a Span also impacts size or other metrics, it should instead implement UpdateLayout.

Let’s go through some examples

1. BackgroundColorSpan

val background = "Background"
val sp = SpannableString(background)
sp.setSpan(BackgroundColorSpan(Color.parseColor("#2ABA8F")), 0, background.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvBackground.text = sp

2. RelativeSizeSpan

val relative = "Relative size"
val sp = SpannableString(relative)
sp.setSpan(RelativeSizeSpan(2.0F), 0, relative.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvRelative.text = sp

3. TypefaceSpan

val typeface = "Typeface"
val sp = SpannableString(typeface)
sp.setSpan(TypefaceSpan("serif"), 0, typeface.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvTypeface.text = sp

4. StrikethroughSpan

val strike = "Strikethrough"
val sp = SpannableString(strike)
sp.setSpan(StrikethroughSpan(), 0, strike.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvStrikethrough.text = sp

5. UnderlineSpan

val underline = "Underline"
val sp = SpannableString(underline)
sp.setSpan(UnderlineSpan(), 0, underline.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvUnderline.text = sp

6. ImageSpan

val image = "Image"
val sp = SpannableString(image)
val drawable = ContextCompat.getDrawable(this, R.drawable.ic_i)
drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
sp.setSpan(ImageSpan(drawable, ImageSpan.ALIGN_BASELINE), 0, 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvImage.text = sp

7. SuperscriptSpan and SubscriptSpan

val superScript = "N2"
val sp = SpannableString(superScript)
sp.setSpan(SuperscriptSpan(), 1, superScript.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSuperScript.text = sp
val subscript = "H2O"
val sp = SpannableString(subscript)
sp.setSpan(SubscriptSpan(), 1, subscript.length - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
tvSubScript.text = sp

8. TextSurroundSpan

9. Custom span

Visit the source code here.

How it helps?

Hunt’s upcoming product, a product detail screen in the Hunt for Android app, is an extreme example.

№1
№2

These white blocks you see are messages delivered by the speaker after a slide in animation driven by LayoutAnimationController. Each message could be fairly tall, and contains several pieces of text, styled text (bold, italic), hyperlink, photo or video (actually a photo with a Youtube icon on top). This makes the task of binding all of the message’s data difficult. Our initial design was based on the following concept: For every piece of a message, we created a UI class according to its type.

So, this meant to display like the first photo we had a TextView, ImageView, then TextView added to a VerticalLinearLayout . This approach was simple and intuitive for finding the right piece of code or knowing where to add it, but had various drawbacks:

  • Deep-View hierarchy: Logically grouping things meant putting them inside the same view which resulted in a complicated hierarchy of views.
  • Boilerplate code: Spending much time on constructing Views dynamically, and binding logic
  • Heavy-lifting method: bind method is supposed to bind data than doing other works. There is no way to wait until the last item to be bound before RecyclerView’s animation. On some low-end devices, it may cause jank during animation.

Making it better

Before trying to solve these problems, we took a step back and considered a another idea: Composing each component of a message of Span. This nice trick results in various advantages right away. Let’s take the above message from photo1 as an example:

LinearLayout(TextView + ImageView + TextView) is mapped to an equivalent UI component TextView(String + ImageSpan + String + UnderlineSpan + ForegroundSpan + String)

It’s time when the concept of Span become handy. It turned out that composing a complex Spanned can be easily done through HTML.fromhtmlsince Android did all the heavy lifting. The rest of the work is just map message’s data to HTML by HTMLBuilder.

This approach has resulted in a few benefits:

  • We were able to simplify each item’s hierarchy to one TextView.
  • The new approach has lent itself to improving our code quality since we moved all the logic out of ViewHolder.

Summary

If different categories of information is displayed, using multiple TextViews makes it easy to control. Otherwise, Spanned is good at dealing with rich text. There are different ways to achieve the same goal, we must weigh the pros and cons and choose the most appropriate one.

We hope this post gives you a taste of what Android offers us to process rich text. It’s been a great learning experience for us as well.

--

--