Spans

Spans are powerful markup objects that you can use to style text at a character or paragraph level. By attaching spans to text objects, you can change text in a variety of ways, including adding color, making the text clickable, scaling the text size, and drawing text in a customized way. Spans can also change TextPaint properties, draw on a Canvas, and even change text layout.

Android provides several types of spans that cover a variety of common text styling patterns. You can also create your own spans to apply custom styling.

Create and apply a span

To create a span, you can use one of the classes listed in the table below. Each class differs based on whether the text itself is mutable, whether the text markup is mutable, and the underlying data structure that contains the span data:

Class Mutable text Mutable markup Data structure
SpannedString No No Linear array
SpannableString No Yes Linear array
SpannableStringBuilder Yes Yes Interval tree

Here’s how to decide which one to use:

  • If you aren't modifying the text or markup after creation, use SpannedString.
  • If you need to attach a small number of spans to a single text object, and the text itself is read-only, use SpannableString.
  • If you need to modify text after creation, and you need to attach spans to the text, use SpannableStringBuilder.
  • If you need to attach a large number of spans to a text object, regardless of whether the text itself is read-only, use SpannableStringBuilder.

All of these classes extend the Spanned interface. SpannableString and SpannableStringBuilder also extend the Spannable interface.

To apply a span, call setSpan(Object _what_, int _start_, int _end_, int _flags_) on a Spannable object. The what parameter refers to the span to apply to the text, while the start and end parameters indicate the portion of the text to which to apply the span.

After applying a span, if you insert text inside of the span boundaries, the span automatically expands to include the inserted text. When inserting text at the span boundaries—that is, at either the start or end indices—the flags parameter determines whether the span should expand to include the inserted text. Use the Spannable.SPAN_EXCLUSIVE_INCLUSIVE flag to include inserted text, and use Spannable.SPAN_EXCLUSIVE_EXCLUSIVE to exclude the inserted text.

The example below shows how to attach a ForegroundColorSpan to a string:

Kotlin

val spannable = SpannableStringBuilder("Text is spantastic!")
spannable.setSpan(
    ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
)

Java

SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!");
spannable.setSpan(
    new ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
);

Figure 1. Text styled with a ForegroundColorSpan.

Because the span was set using Spannable.SPAN_EXCLUSIVE_INCLUSIVE, the span expands to include inserted text at the span boundaries, as shown in the example below:

Kotlin

val spannable = SpannableStringBuilder("Text is spantastic!")
spannable.setSpan(
    ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
)
spannable.insert(12, "(& fon)")

Java

SpannableStringBuilder spannable = new SpannableStringBuilder("Text is spantastic!");
spannable.setSpan(
    new ForegroundColorSpan(Color.RED),
    8, // start
    12, // end
    Spannable.SPAN_EXCLUSIVE_INCLUSIVE
);
spannable.insert(12, "(& fon)");

Figure 2. The span expands to include additional text when using Spannable.SPAN_EXCLUSIVE_INCLUSIVE.

You can attach multiple spans to the same text. The example below shows how to create text that is both bold and red:

Kotlin

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
)

Java

SpannableString spannable = SpannableString(“Text is spantastic!”);
spannable.setSpan(
    new ForegroundColorSpan(Color.RED),
    8, 12,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);
spannable.setSpan(
    new StyleSpan(BOLD),
    8, spannable.length,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
);

Figure 3. Text with multiple spans: ForegroundColorSpan(Color.RED) and StyleSpan(BOLD)

Android span types

Android provides over 20 span types in the android.text.style package. Android categorizes spans in two primary ways:

  • How the span affects text: a span can affect either text appearance or text metrics.
  • Span scope: some spans can be applied to individual characters, while others must be applied to an entire paragraph.

Figure 4. Span categories: character vs paragraph, appearance vs metric

The sections below describe these categories in more detail.

Spans that affect text appearance

Spans can affect text appearance, such as changing text or background color and adding underlines or strikethroughts. All of these spans extend the CharacterStyle class.

The code example below shows how to apply an UnderlineSpan to underline the text:

Kotlin

val string = SpannableString("Text with underline span")
string.setSpan(UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Java

SpannableString string = new SpannableString("Text with underline span");
string.setSpan(new UnderlineSpan(), 10, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

Figure 5. Underlining text using an UnderlineSpan

Spans that affect only text appearance trigger a redraw of the text without triggering a recalculation of the layout. These spans implement UpdateAppearance and extend CharacterStyle. CharacterStyle subclasses define how to draw text by providing access to update the TextPaint.

Spans that affect text metrics

Spans can also affect text metrics, such as line height and text size. All of these spans extend the MetricAffectingSpan class.

The code example below creates a RelativeSizeSpan that increases text size by 50%:

Kotlin

val string = SpannableString("Text with relative size span")
string.setSpan(RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Java

SpannableString string = new SpannableString("Text with relative size span");
string.setSpan(new RelativeSizeSpan(1.5f), 10, 24, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

Figure 6. Setting text size using a RelativeSizeSpan

Applying a span that affects text metrics causes an observing object to remeasure the text for correct layout and rendering—changing text size might cause words to appear on different lines, for example. Applying the span above triggers a remeasure, a recalculation of the text layout, and a redrawing of the text. These spans usually extend the MetricAffectingSpan class, which is an abstract class that allows subclasses to define how the span affects text measurement by providing access to the TextPaint. Since MetricAffectingSpan extends CharacterSpan, subclasses affect the appearance of the text at the character level.

Spans that affect individual characters

A span can affect text at a character level. For example, you can update character elements like background color, style, or size. Spans that affect individual characters extend the CharacterStyle class.

The code example below attaches a BackgroundColorSpan to a subset of characters in the text:

Kotlin

val string = SpannableString("Text with a background color span")
string.setSpan(BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

Java

SpannableString string = new SpannableString("Text with a background color span");
string.setSpan(new BackgroundColorSpan(color), 12, 28, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

Figure 7. Applying a BackgroundColorSpan to text

Spans that affect paragraphs

A span can also affect text at a paragraph level, such as changing the alignment or the margin of the entire block of text. Spans that affect entire paragraphs implement ParagraphStyle. When using these spans, you must attach them to the entire paragraph, excluding the ending new line character. If you try to apply a paragraph span to something other than a whole paragraph, Android does not apply the span at all.

Figure 8 shows how Android separates paragraphs in text.

Figure 8. In Android, paragraphs end with a new line ('\n') character.

The following code example applies a QuoteSpan to an entire paragraph. Note that if you attach the span to any positions other than the beginning and end of a paragraph, Android does not apply the style at all:

Kotlin

spannable.setSpan(QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

Java

spannable.setSpan(new QuoteSpan(color), 8, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

Figure 9. Applying a QuoteSpan to a paragraph

Create custom spans

If you need more functionality than what is provided in the existing Android spans, you can implement a custom span. When implementing your own span, you need to decide whether your span affects text at a character or paragraph level and also whether it affects the layout or appearance of the text. This helps you determine which base classes you can extend and which interfaces you might need to implement. Use the table below for reference:

Scenario Class or interface
Your span affects text at the character level. CharacterStyle
Your span affects text at the paragraph level. ParagraphStyle
Your span affects text appearance. UpdateAppearance
Your span affects text metrics. UpdateLayout

As an example, if you need to implement a custom span that allows for modifying text size and color, you can extend RelativeSizeSpan. Since this class already provides callbacks for updateDrawState and updateMeasureState, you can override these callbacks to implement your custom behavior. The code below creates a custom span that extends RelativeSizeSpan and overrides the updateDrawState callback to set the color of the TextPaint:

Kotlin

class RelativeSizeColorSpan(
    size: Float,
    @ColorInt private val color: Int
) : RelativeSizeSpan(size) {
    override fun updateDrawState(textPaint: TextPaint?) {
        super.updateDrawState(textPaint)
        textPaint?.color = color
    }
}

Java

public class RelativeSizeColorSpan extends RelativeSizeSpan {
    private int color;
    public RelativeSizeColorSpan(float spanSize, int spanColor) {
        super(spanSize);
        color = spanColor;
    }
    @Override
    public void updateDrawState(TextPaint textPaint) {
        super.updateDrawState(textPaint);
        textPaint.setColor(color);
    }
}

Note that this example simply illustrates how to create a custom span. You can achieve the same effect by applying a RelativeSizeSpan and ForegroundColorSpan to the text.

Test span usage

The Spanned interface allows for both setting spans and retrieving spans from text. When testing, you should implement an Android JUnit test to verify that the correct spans are added at the correct locations. The android-text sample contains a span that applies markup to bullet points by attaching BulletPointSpans to the text. The code example below shows how to test that the bullet points appear as expected:

Kotlin

@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())
}

Java

@Test
public void textWithBulletPoints() {
    SpannedString 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
    Object[] spans = result.getSpans(0, result.length(), Object.class);
    assertEquals(2, spans.length);

    // check that the span is indeed a BulletPointSpan
    BulletPointSpan bulletSpan = (BulletPointSpan) spans[0];

    // check that the start and end indexes are the expected ones
    assertEquals(7, result.getSpanStart(bulletSpan));
    assertEquals(11, result.getSpanEnd(bulletSpan));
    BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1];
    assertEquals(11, result.getSpanStart(bulletSpan2));
    assertEquals(14, result.getSpanEnd(bulletSpan2));
}

For more test examples, see MarkdownBuilderTest.

Testing custom span implementation

When testing spans, you should verify that the TextPaint contains the expected modifications and that the correct elements appear on your Canvas. For example, consider a custom span implementation that prepends a bullet point to some text. The bullet point has a specified size and color, and a gap exists between the left margin of the drawable area and the bullet point.

You can test the behavior of this class by implementing an AndroidJUnit test, checking for the following:

  • If you correctly apply the span, a bullet point of the specified size and color appears on the canvas, and the proper space exists between the left margin and the bullet point
  • If you don't apply the span, none of the custom behavior appears

See the implementation of these tests in the android-text sample.

You can test Canvas interactions by mocking the canvas, passing the mocked object to the drawLeadingMargin() method, and verifying that the correct methods are called with the correct parameters, as shown in the example below:

Kotlin

val GAP_WIDTH = 5
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 yCoordinate = (top + bottom) / 2f
    inOrder.verify(canvas)
           .drawCircle(
                eq(xCoordinate),
                eq(yCoordinate),
                eq(BulletPointSpan.DEFAULT_BULLET_RADIUS),
                eq(paint)
            )
    verify(canvas, never()).save()
    verify(canvas, never()).translate(
               eq(xCoordinate),
               eq(yCoordinate)
    )
}

Java

private int GAP_WIDTH = 5;
private Canvas canvas = mock(Canvas.class);
private Paint paint = mock(Paint.class);
private SpannableString text = new SpannableString("text");

@Test
public void drawLeadingMargin() {
    int x = 10;
    int dir = 15;
    int top = 5;
    int bottom = 7;
    int color = Color.RED;

    // Given a span that is set on a text
    BulletPointSpan span = new 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));

    // Check that the correct canvas and paint methods are called, in the correct order
    InOrder inOrder = inOrder(canvas, paint);
    inOrder.verify(paint).setColor(color);
    inOrder.verify(paint).setStyle(eq(Paint.Style.FILL));
    // a circle with the correct size is drawn
    // at the correct location
    int xCoordinate = (float)GAP_WIDTH + (float)x
        + dir * BulletPointSpan.BULLET_RADIUS;
    int yCoordinate = (top + bottom) / 2f;
    inOrder.verify(canvas)
           .drawCircle(
                eq(xCoordinate),
                eq(yCoordinate),
                eq(BulletPointSpan.BULLET_RADIUS),
                eq(paint));
    verify(canvas, never()).save();
    verify(canvas, never()).translate(
            eq(xCoordinate),
            eq(yCoordinate);
}

You can find more span tests in BulletPointSpanTest.

Best practices for using spans

There are several memory-efficient ways of setting text in a TextView, depending on your needs.

Attach or detach a span without changing the underlying text

TextView.setText() contains multiple overloads that handle spans differently. For example, you can set a Spannable text object with the following code:

Kotlin

textView.setText(spannableObject)

Java

textView.setText(spannableObject);

When calling this overload of setText(), the TextView creates a copy of your Spannable as a SpannedString and keeps it in memory as a CharSequence. This means that your text and the spans are immutable, so when you need to update the text or the spans, you need to create a new Spannable object and call setText() again, which also triggers a re-measuring and re-drawing of the layout.

To indicate that the spans should be mutable, you can instead use setText(CharSequence text, TextView.BufferType type), as shown in the following example:

Kotlin

textView.setText(spannable, BufferType.SPANNABLE)
val spannableText = textView.text as Spannable
spannableText.setSpan(
     ForegroundColorSpan(color),
     8, spannableText.length,
     SPAN_INCLUSIVE_INCLUSIVE
)

Java

textView.setText(spannable, BufferType.SPANNABLE);
Spannable spannableText = (Spannable) textView.getText();
spannableText.setSpan(
     new ForegroundColorSpan(color),
     8, spannableText.getLength(),
     SPAN_INCLUSIVE_INCLUSIVE);

In this example, because of the BufferType.SPANNABLE parameter, the TextView creates a SpannableString, and the CharSequence object kept by the TextView now has mutable markup and immutable text. To update the span, we can retrieve the text as a Spannable and then update the spans as needed.

When you attach, detach, or reposition spans, the TextView automatically updates to reflect the change to the text. Note, however, that if you change an internal attribute of an existing span, you need to also call either invalidate() if making appearance-related changes or requestLayout() if making metric-related changes.

Set text in a TextView multiple times

In some cases, such as when using a RecyclerView.ViewHolder, you might want to reuse a TextView and set the text multiple times. By default, regardless of whether you set the BufferType, the TextView creates a copy of the CharSequence object and holds it in memory. This ensures that all TextView updates are intentional—you can't simply update the original CharSequence object to update the text. This means that every time you set new text, the TextView creates a new object.

If you’d like to take more control over this process and avoid the extra object creation, you can implement your own Spannable.Factory and override newSpannable(). Instead of creating a new text object, you can simply cast and return the existing CharSequence as a Spannable, as demonstrated below:

Kotlin

val spannableFactory = object : Spannable.Factory() {
    override fun newSpannable(source: CharSequence?): Spannable {
        return source as Spannable
    }
}

Java

Spannable.Factory spannableFactory = new Spannable.Factory(){
    @Override
    public Spannable newSpannable(CharSequence source) {
        return (Spannable) source;
    }
};

Note that you must use textView.setText(spannableObject, BufferType.SPANNABLE) when setting the text. Otherwise, the source CharSequence is created as a Spanned instance and cannot be cast to Spannable, causing newSpannable() to throw a ClassCastException.

After overriding newSpannable(), you need to tell the TextView to use the new Factory:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

Be sure to set the Spannable.Factory object once right after you get a reference to your TextView. If you’re using a RecyclerView, set the Factory object when you first inflate your views. This avoids extra object creation when your RecyclerView binds a new item to your ViewHolder.

Change internal span attributes

If you need to change only an internal attribute of a mutable span, such as the bullet color in a custom bullet span, you can avoid the overhead from calling setText() multiple times by keeping a reference to the span as it's created. When you need to modify the span, you can modify the reference and then call either invalidate() or requestLayout() on the TextView, depending on the type of attribute that you changed.

In the code example below, a custom bullet point implementation has a default color of red that changes to gray when clicking a button:

Kotlin

class MainActivity : AppCompatActivity() {

    // keeping the span as a field
    val bulletSpan = BulletPointSpan(color = Color.RED)

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val spannable = SpannableString("Text is spantastic")
        // setting the span to the bulletSpan field
        spannable.setSpan(
            bulletSpan,
            0, 4,
            Spanned.SPAN_INCLUSIVE_INCLUSIVE
        )
        styledText.setText(spannable)
        button.setOnClickListener {
            // change the color of our mutable span
            bulletSpan.color = Color.GRAY
            // color won’t be changed until invalidate is called
            styledText.invalidate()
        }
    }
}

Java

public class MainActivity extends AppCompatActivity {

    private BulletPointSpan bulletSpan = new BulletPointSpan(Color.RED);

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        SpannableString spannable = new SpannableString("Text is spantastic");
        // setting the span to the bulletSpan field
        spannable.setSpan(bulletSpan, 0, 4, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
        styledText.setText(spannable);
        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                // change the color of our mutable span
                bulletSpan.setColor(Color.GRAY);
                // color won’t be changed until invalidate is called
                styledText.invalidate();
            }
        });
    }
}

Use Android KTX extension functions

Android KTX also contains extension functions that make working with spans even easier. To learn more, see the documentation for the androidx.core.text package.