Image for post
Image for post
Illustration by Virginia Poltrack

Spantastic text styling with Spans

Florina Muntenescu
Mar 29, 2018 · 11 min read

Styling text in Android

Android offers several ways of styling text:

  • Multi style — where several styles can be applied to a text, at character or paragraph level
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="32sp"
android:textStyle="bold"
/>
Image for post
Image for post
Left: Single style text. TextView with textSize=”32sp” and textStyle=”bold”. Right: Multi style text. Text with ForegroundColorSpan, StyleSpan(ITALIC), ScaleXSpan(1.5f), StrikethroughSpan.
val text = "My text <ul><li>bullet one</li><li>bullet two</li></ul>"
myTextView.text = Html.fromHtml(text)
val spannable = SpannableString("My text \nbullet one\nbullet two")spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* start index */ 9, /* end index */ 18,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(
BulletPointSpan(gapWidthPx, accentColor),
/* start index */ 20, /* end index */ spannable.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
myTextView.text = spannable
Image for post
Image for post
Left: Using HTML tags. Center: Using BulletSpan with default bullet size. Right: Using BulletSpan on Android P or custom implementation.
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/blue"/>
val spannable = SpannableString(“Text styling”)
spannable.setSpan(
ForegroundColorSpan(Color.PINK),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
myTextView.text = spannable
Image for post
Image for post
Combining TextView with XML attributes and text with spans

Applying Spans

When using spans, you will work with one of the following classes: SpannedString, SpannableString or SpannableStringBuilder. The difference between them lies in whether the text or the markup objects are mutable or immutable and in the internal structure they use: SpannedString and SpannableString use linear arrays to keep records of added spans, whereas SpannableStringBuilder uses an interval tree.

  • Setting the text and the spans? -> SpannableStringBuilder
  • Setting a small number of spans (<~10)? -> SpannableString
  • Setting a larger number of spans (>~10) -> SpannableStringBuilder
╔════════════════════════╦══════════════╦════════════════╗
ClassMutable TextMutable Markup
╠════════════════════════╬══════════════╬════════════════╣
║ SpannedString ║ no ║ no ║
║ SpannableString ║ no ║ yes ║
║ SpannableStringBuilder ║ yes ║ yes ║
╚════════════════════════╩══════════════╩════════════════╝
val spannable = SpannableStringBuilder(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
8, 12,
Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
val spannable = SpannableStringBuilder(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
/* start index */ 8, /* end index */ 12,
Spannable.SPAN_EXCLUSIVE_INCLUSIVE)
spannable.insert(12, “(& fon)”)
Image for post
Image for post
Left: Text with ForegroundColorSpan. Right: Text with ForegroundColorSpan and Spannable.SPAN_EXCLUSIVE_INCLUSIVE
val spannable = SpannableString(“Text is spantastic!”)spannable.setSpan(
ForegroundColorSpan(Color.RED),
8, 12,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(
StyleSpan(BOLD),
8, spannable.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Image for post
Image for post
Text with multiple spans: ForegroundColorSpan(Color.RED) and StyleSpan(BOLD)

Framework spans

The Android framework defines several interfaces and abstract classes that are checked at measure and render time. These classes have methods that allow a span to access objects like the TextPaint or the Canvas.

  • Based on whether they affect text at character or at paragraph level
Image for post
Image for post
Span categories: character vs paragraph, appearance vs metric

Appearance vs metric affecting spans

The first category affects character-level text in a way that modifies their appearance: text or background colour, underline, strikethrough, etc., that triggers a redraw without causing a relayout of the text. These spans implement UpdateAppearance and extend CharacterStyle. CharacterStyle subclasses define how to draw text by providing access to update the TextPaint.

Image for post
Image for post
Appearance affecting spans
Image for post
Image for post
Metric affecting spans
val spannableString = SpannableString(“Spantastic text”)// setting the text as a Spannable
textView.setText(spannableString, BufferType.SPANNABLE)
// later getting the instance of the text object held
// by the TextView
// this can can be cast to Spannable only because we set it as a
// BufferType.SPANNABLE before
val spannableText = textView.text as Spannable
spannableText.setSpan(
ForegroundColorSpan(colorAccent),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannableText.setSpan(
RelativeSizeSpan(2f),
0, 4,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Image for post
Image for post
Left: ForegroundColorSpan — appearance affecting span. Right: RelativeSizeSpan — metric affecting span

Character vs paragraph affecting spans

A span can either affect the text at the character level, updating elements like background colour, style or size, or a the paragraph level, changing the alignment or the margin of the entire block of text. Depending on the needed styling, spans either extend CharacterStyle or implement ParagraphStyle. Spans that extend ParagraphStyle must be attached from the first character to the last character of a single paragraph, otherwise the span will not be displayed. On Android paragraphs are defined based on new line (\n) character.

Image for post
Image for post
On Android paragraphs are defined based on new line (‘\n’) character.
Image for post
Image for post
Paragraph affecting spans
val spannable = SpannableString(“Text is\nspantastic”)spannable.setSpan(
BackgroundColorSpan(color),
5, 8,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(
QuoteSpan(color),
8, text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
Image for post
Image for post
Left: BackgroundColorSpan — character affecting span. Right: QuoteSpan — paragraph affecting span

Creating custom spans

When implementing your own span, you will need to decide whether your span affects the text at character or paragraph level and whether it also affects the layout or just the appearance of the text. But, before writing your own implementations from scratch, check whether you can use the functionality provided in the framework spans.

  • Affecting text at the paragraph level -> ParagraphStyle
  • Affecting text appearance -> UpdateAppearance
  • Affecting text metrics -> UpdateLayout
class RelativeSizeColorSpan(
@ColorInt private val color: Int,
size: Float
) : RelativeSizeSpan(size) {
override fun updateDrawState(textPaint: TextPaint?) {
super.updateDrawState(ds)
textPaint?.color = color
}
}

Testing custom spans implementation

Testing spans means checking that indeed the expected modifications have been made on the TextPaint or that the correct elements have been drawn on to your Canvas. For example, consider the custom implementation of a span that adds a bullet point, of a specified size and color to a paragraph, together with a gap between the left margin and the bullet point. See the implementation in the android-text sample. To test this class implement an AndroidJUnit test, checking that indeed:

  • Nothing is drawn if the span is not attached to text
  • The correct margin is set, based on the constructor parameters values
val canvas = mock(Canvas::class.java)
val paint = mock(Paint::class.java)
val text = SpannableString("text")

@Test fun drawLeadingMargin() {
val x = 10
val dir = 15
val top = 5
val bottom = 7
val color = Color.RED
// Given a span that is set on a text
val span = BulletPointSpan(GAP_WIDTH, color)
text.setSpan(span, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
// When the leading margin is drawn
span.drawLeadingMargin(canvas, paint, x, dir, top, 0, bottom,
text, 0, 0, true, mock(Layout::class.java))
// Check that the correct canvas and paint methods are called,
//in the correct order
val inOrder = inOrder(canvas, paint)
// bullet point paint color is the one we set
inOrder.verify(paint).color = color
inOrder.verify(paint).style = eq<Paint.Style>(Paint.Style.FILL)
// a circle with the correct size is drawn
// at the correct location
val xCoordinate = GAP_WIDTH.toFloat() + x.toFloat()
+dir * BulletPointSpan.DEFAULT_BULLET_RADIUS
val yCoord = (top + bottom) / 2f
inOrder.verify(canvas)
.drawCircle(
eq(xCoordinate),
eq(yCoord),
eq(BulletPointSpan.DEFAULT_BULLET_RADIUS),
eq(paint))
verify(canvas, never()).save()
verify(canvas, never()).translate(
eq(xCoordinate),
eq(yCoordinate))
}

Testing spans usage

The Spanned interface allows both setting and retrieving spans from text. Check that the correct spans are added at the correct locations by implementing an Android JUnit test. In the android-text sample we’re converting bullet point markup tags to bullet points. This is done by attaching BulletPointSpans to the text, at the correct location. Here’s how it can be tested:

@Test fun textWithBulletPoints() {
val result = builder.markdownToSpans(“Points\n* one\n+ two”)
// check that the markup tags are removed
assertEquals(“Points\none\ntwo”, result.toString())
// get all the spans attached to the SpannedString
val spans = result.getSpans<Any>(0, result.length, Any::class.java)assertEquals(2, spans.size.toLong())
// check that the span is indeed a BulletPointSpan
val bulletSpan = spans[0] as BulletPointSpan
// check that the start and end indexes are the expected ones
assertEquals(7, result.getSpanStart(bulletSpan).toLong())
assertEquals(11, result.getSpanEnd(bulletSpan).toLong())
val bulletSpan2 = spans[1] as BulletPointSpan
assertEquals(11, result.getSpanStart(bulletSpan2).toLong())
assertEquals(14, result.getSpanEnd(bulletSpan2).toLong())
}

Android Developers

The official Android Developers publication on Medium

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app