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(Typeface.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(Typeface.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 strikethroughs. 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
Text Styling sample
contains a span that applies markup to bullet points by attaching
BulletPointSpan
s 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 were 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) // check that the correct number of spans were created assertEquals(2, spans.size.toLong()) // check that the spans are instances of BulletPointSpan val bulletSpan1 = spans[0] as BulletPointSpan val bulletSpan2 = spans[1] as BulletPointSpan // check that the start and end indices are the expected ones assertEquals(7, result.getSpanStart(bulletSpan1).toLong()) assertEquals(11, result.getSpanEnd(bulletSpan1).toLong()) 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 were removed assertEquals("Points\none\ntwo", result.toString()); // get all the spans attached to the SpannedString Object[] spans = result.getSpans(0, result.length(), Object.class); // check that the correct number of spans were created assertEquals(2, spans.length); // check that the spans are instances of BulletPointSpan BulletPointSpan bulletSpan1 = (BulletPointSpan) spans[0]; BulletPointSpan bulletSpan2 = (BulletPointSpan) spans[1]; // check that the start and end indices are the expected ones assertEquals(7, result.getSpanStart(bulletSpan1)); assertEquals(11, result.getSpanEnd(bulletSpan1)); 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 TextStyling 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.