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 aSpan
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 = spval 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.
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.fromhtml
since 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.