Intervalos

Los intervalos son objetos de lenguaje de marcado eficaces que puedes usar para dar estilo al texto a nivel de carácter o párrafo. Si adjuntas intervalos a los objetos de texto, puedes cambiar el texto de varias maneras, como agregar color, agregar la posibilidad de hacer clics, ajustar el tamaño y dibujarlo de manera personalizada. Los intervalos también pueden cambiar las propiedades TextPaint, dibujar en un Canvas o incluso cambiar el diseño del texto.

Android proporciona varios tipos de intervalos que abarcan una variedad de patrones de estilo de texto comunes. También puedes crear tus propios intervalos para aplicar un estilo personalizado.

Cómo crear y aplicar un intervalo

Para crear un intervalo, puedes usar una de las clases que se incluyen en la siguiente tabla. Cada clase difiere en función de si el lenguaje de marcado del texto o el texto en sí son mutables y de la estructura de datos subyacente que contiene los datos del intervalo:

Clase Texto mutable Lenguaje de marcado mutable Estructura de datos
SpannedString No No Arreglo lineal
SpannableString No Arreglo lineal
SpannableStringBuilder Árbol de intervalos

Sigue estos pasos para decidir cuál usar:

  • Si no tienes pensado modificar el texto o el lenguaje de marcado después de crearlo, usa SpannedString.
  • Si necesitas adjuntar una pequeña cantidad de intervalos a un solo objeto de texto, y el texto en sí mismo es de solo lectura, usa SpannableString.
  • Si necesitas modificar el texto después de crearlo y debes adjuntar intervalos al texto, usa SpannableStringBuilder.
  • Si necesitas adjuntar una gran cantidad de intervalos a un objeto de texto, independientemente de si es de solo lectura, usa SpannableStringBuilder.

Todas estas clases extienden la interfaz Spanned. SpannableString y SpannableStringBuilder también extienden la interfaz Spannable.

Para aplicar un intervalo, llama a setSpan(Object _what_, int _start_, int _end_, int _flags_) en un objeto Spannable. El parámetro what se refiere al intervalo que se aplicará al texto, mientras que start y end indican la parte del texto a la que se aplicará el intervalo.

Después de aplicar un intervalo, si insertas texto dentro de sus límites, se expandirá automáticamente para incluir el texto insertado. Al insertar texto en los límites del intervalo, es decir, en los índices start o end, el parámetro flags determina si se debe expandir el intervalo para incluir el texto insertado. Usa la marca Spannable.SPAN_EXCLUSIVE_INCLUSIVE para incluir el texto insertado y Spannable.SPAN_EXCLUSIVE_EXCLUSIVE para excluirlo.

En el siguiente ejemplo, se muestra cómo adjuntar un ForegroundColorSpan a una 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
);

Figura 1: Texto con estilo ForegroundColorSpan

Dado que se lo configuró con Spannable.SPAN_EXCLUSIVE_INCLUSIVE, el intervalo se expande para incluir el texto insertado dentro de sus límites, como se muestra en el siguiente ejemplo:

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)");

Figura 2: El intervalo se expande para incluir texto adicional cuando se usa Spannable.SPAN_EXCLUSIVE_INCLUSIVE

Puedes adjuntar varios intervalos al mismo texto. En el siguiente ejemplo, se muestra cómo crear texto en negrita y color rojo:

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
);

Figura 3: Texto con varios intervalos: ForegroundColorSpan(Color.RED) y StyleSpan(BOLD)

Tipos de intervalo de Android

Android ofrece más de 20 tipos de intervalo en el paquete android.text.style. Android categoriza los intervalos de dos formas principales:

  • Cómo afecta al texto un intervalo: Un intervalo puede afectar la apariencia o las métricas del texto.
  • El alcance del intervalo: Algunos intervalos se pueden aplicar a caracteres individuales, mientras que otros se deben aplicar a un párrafo completo.

Figura 4: Características del intervalo: carácter frente a párrafo y apariencia frente a métrica

En las secciones que aparecen a continuación, se describen estas categorías de manera más detallada.

Intervalos que afectan la apariencia del texto

Los intervalos pueden afectar la apariencia del texto; por ejemplo, pueden cambiar el color del texto o del fondo, y agregar contenido subrayado o tachado. Todos estos intervalos extienden la clase CharacterStyle.

En el siguiente ejemplo de código, se muestra cómo aplicar un UnderlineSpan para subrayar texto:

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);

Figura 5: Subrayado de texto con un UnderlineSpan

Los intervalos que afectan solo la apariencia del texto activan un rediseño del texto sin activar un nuevo cálculo del diseño. Estos intervalos implementan UpdateAppearance y extienden CharacterStyle. Las subclases de CharacterStyle definen cómo diseñar el texto permitiendo el acceso para actualizar TextPaint.

Intervalos que afectan las métricas del texto

Los intervalos también pueden afectar las métricas del texto, como la altura de la línea y el tamaño del texto. Todos estos intervalos extienden la clase MetricAffectingSpan.

El siguiente ejemplo de código crea un RelativeSizeSpan que aumenta el tamaño del texto en un 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);

Figura 6: Configuración del tamaño del texto con un RelativeSizeSpan

Aplicar un intervalo que afecta las métricas del texto hace que un objeto de observación vuelva a medir el texto para un diseño y procesamiento correctos; por ejemplo, si se cambia el tamaño del texto, es posible que las palabras aparezcan en diferentes líneas. La aplicación del intervalo anterior activa una nueva medición, así como un nuevo cálculo de diseño y un rediseño del texto. Por lo general, estos intervalos extienden MetricAffectingSpan, una clase abstracta que otorga acceso a TextPaint y, así, permite que las subclases definan cómo el intervalo afecta la medición del texto. Como MetricAffectingSpan extiende CharacterSpan, las subclases afectan la apariencia del texto a nivel de los caracteres.

Intervalos que afectan caracteres individuales

Un intervalo puede afectar el texto a nivel de carácter. Por ejemplo, puedes actualizar elementos de caracteres, como el color de fondo, el estilo o el tamaño. Los intervalos que afectan a los caracteres individuales extienden la clase CharacterStyle.

El siguiente ejemplo de código adjunta un BackgroundColorSpan a un subconjunto de caracteres en el texto:

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);

Figura 7: Aplicación de un BackgroundColorSpan al texto

Intervalos que afectan párrafos

Un intervalo también puede afectar el texto a nivel de párrafo, como cambiar la alineación o el margen de todo el bloque de texto. Los intervalos que afectan los párrafos completos implementan ParagraphStyle. Cuando uses esos intervalos, deberás adjuntarlos al párrafo completo y excluir el carácter final de la nueva línea. Si intentas aplicar un intervalo de párrafo a otro elemento que no sea un párrafo completo, Android no aplicará el intervalo.

En la Figura 8, se muestra cómo Android separa los párrafos en el texto.

Figura 8: En Android, los párrafos terminan con un carácter de línea nueva ('\n').

El siguiente ejemplo de código aplica un QuoteSpan a un párrafo completo. Ten en cuenta que si adjuntas el intervalo a alguna posición que no sea el principio o el final de un párrafo, Android no aplicará el estilo:

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);

Figura 9: Cómo aplicar un QuoteSpan a un párrafo

Cómo crear intervalos personalizados

Si necesitas más funciones que las que se ofrecen en los intervalos existentes de Android, puedes implementar un intervalo personalizado. Cuando implementes tu propio intervalo, deberás decidir si quieres que afecte el texto a nivel de carácter o párrafo, así como el diseño o la apariencia del texto. De esta forma, podrás determinar qué clases básicas puedes extender y qué interfaces deberías implementar. Usa la siguiente tabla como referencia:

Situación Clase o interfaz
El intervalo afecta el texto a nivel de carácter. CharacterStyle
El intervalo afecta el texto a nivel de párrafo. ParagraphStyle
El intervalo afecta la apariencia del texto. UpdateAppearance
El intervalo afecta las métricas del texto. UpdateLayout

Como ejemplo, si necesitas implementar un intervalo personalizado que permita modificar el tamaño y el color del texto, puedes extender RelativeSizeSpan. Como esta clase ya permite devoluciones de llamada de updateDrawState y updateMeasureState, puedes anular esas devoluciones de llamada para implementar tu comportamiento personalizado. El siguiente código crea un intervalo personalizado que extiende RelativeSizeSpan y anula la devolución de llamada updateDrawState a fin de configurar el color de 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);
    }
}

Ten en cuenta que este ejemplo solo ilustra cómo crear un intervalo personalizado. Puedes lograr el mismo efecto mediante la aplicación de RelativeSizeSpan y ForegroundColorSpan al texto.

Uso del intervalo de prueba

La interfaz Spanned permite configurar y recuperar intervalos de texto. Cuando hagas la prueba, debes implementar una prueba JUnit de Android de modo que verifiques que se agreguen los intervalos correctos en las ubicaciones adecuadas. El ejemplo de estilo de texto contiene un intervalo que aplica el lenguaje de marcado a las viñetas adjuntando BulletPointSpans al texto. En el siguiente ejemplo de código, se muestra cómo probar que las viñetas aparezcan de la manera esperada:

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));
}

Para obtener más ejemplos de prueba, consulta MarkdownBuilderTest.

Cómo probar la implementación de intervalos personalizados

Cuando pruebes los intervalos, verifica que TextPaint contenga las modificaciones esperadas y que los elementos correctos aparezcan en tu Canvas. Por ejemplo, piensa en la implementación de un intervalo personalizado que anexa una viñeta a alguna parte del texto. La viñeta tiene un color y un tamaño específicos, y existe un espacio entre el margen izquierdo del área del elemento de diseño y la viñeta.

Puedes probar el comportamiento de esta clase mediante la implementación de una prueba AndroidJUnit y verificar lo siguiente:

  • Si aplicas correctamente el intervalo, aparecerá una viñeta del tamaño y el color especificados en el lienzo, y se aplicará el espacio adecuado entre el margen izquierdo y la viñeta.
  • Si no aplicas el intervalo, no aparecerá ninguno de los comportamientos personalizados.

Consulta la implementación de estas pruebas en el ejemplo de TextStylingKotlin.

Puedes probar las interacciones de Canvas simulando el lienzo, pasando el objeto simulado al método drawLeadingMargin() y verificando que se llamen los métodos correctos con los parámetros adecuados, como se muestra en el siguiente ejemplo:

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);
}

Puedes encontrar más pruebas de intervalo en BulletPointSpanTest.

Prácticas recomendadas para usar intervalos

Existen varias maneras eficientes en términos de memoria de configurar el texto en una TextView según tus necesidades.

Cómo adjuntar o separar un intervalo sin cambiar el texto subyacente

TextView.setText() contiene varias sobrecargas que controlan los intervalos de diferente manera. Por ejemplo, puedes configurar un objeto de texto Spannable con el siguiente código:

Kotlin

textView.setText(spannableObject)

Java

textView.setText(spannableObject);

Cuando llamas a esta sobrecarga de setText(), TextView crea una copia de tu Spannable como SpannedString y la guarda en la memoria como CharSequence. Esto significa que el texto y los intervalos son inmutables. Por lo tanto, cuando necesitas actualizar el texto o los intervalos, debes crear un nuevo objeto Spannable y volver a llamar a setText(), que también activa una nueva medición y un nuevo dibujo del diseño.

Para indicar que los intervalos deben ser mutables, puedes usar setText(CharSequence text, TextView.BufferType type), como se muestra en el siguiente ejemplo:

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);

En este ejemplo, debido al parámetro BufferType.SPANNABLE, la TextView crea una SpannableString, y el objeto CharSequence que mantiene TextView ahora tiene lenguaje de marcado mutable y texto inmutable. Para actualizar el intervalo, podemos recuperar el texto como Spannable y, luego, actualizar los intervalos según sea necesario.

Cuando adjuntas, separas o cambias de posición los intervalos, TextView se actualiza automáticamente para reflejar el cambio en el texto. Sin embargo, ten en cuenta que, si cambias un atributo interno de un intervalo existente, también debes llamar a invalidate() si realizas cambios relacionados con la apariencia, o a requestLayout() si realizas cambios relacionados con la métrica.

Cómo configurar texto en TextView varias veces

En algunos casos, como cuando se usa un RecyclerView.ViewHolder, es posible que quieras reutilizar un TextView y configurar el texto varias veces. De forma predeterminada, independientemente de si configuras el BufferType, la TextView crea una copia del objeto CharSequence y la retiene en la memoria. Esto garantiza que todas las actualizaciones de TextView sean intencionales; no puedes actualizar el objeto CharSequence original para actualizar el texto sin más. Esto significa que cada vez que configuras un nuevo texto, TextView crea un nuevo objeto.

Si quieres tener más control sobre este proceso y evitar la creación de objetos adicionales, puedes implementar tu propio Spannable.Factory y anular newSpannable(). En vez de crear un nuevo objeto de texto, simplemente puedes convertir y mostrar el CharSequence existente como Spannable, como se muestra a continuación:

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;
    }
};

Ten en cuenta que debes usar textView.setText(spannableObject, BufferType.SPANNABLE) cuando configures el texto. De lo contrario, el CharSequence fuente se crea como una instancia Spanned y no se puede convertir a Spannable, lo que hace que newSpannable() arroje una ClassCastException.

Después de anular newSpannable(), debes indicarle a TextView que use el nuevo Factory:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

Asegúrate de configurar el objeto Spannable.Factory inmediatamente después de obtener una referencia para tu TextView. Si estás usando un RecyclerView, configura el objeto Factory cuando aumentes las vistas por primera vez. Esto evita la creación de objetos adicionales cuando RecyclerView vincula un nuevo elemento a tu ViewHolder.

Cómo cambiar los atributos de intervalos internos

Si necesitas cambiar solo un atributo interno de un intervalo mutable, como el color de la viñeta en un intervalo personalizado, puedes evitar que la sobrecarga llame a setText() varias veces si mantienes una referencia al intervalo a medida que se crea. Cuando necesites modificar el intervalo, puedes modificar la referencia y, luego, llamar a invalidate() o requestLayout() en la TextView, según el tipo de atributo que hayas cambiado.

En el siguiente ejemplo de código, una implementación personalizada de viñetas es de color rojo de forma predeterminada y cambia a color gris cuando se hace clic en un botón:

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

Cómo usar las funciones de extensión de Android KTX

Android KTX también contiene funciones de extensión que facilitan aún más el trabajo con los intervalos. Para obtener más información, consulta la documentación del paquete androidx.core.text.