Períodos

Os períodos são poderosos objetos de marcação que podem ser usados para definir o estilo do texto em nível de caractere ou parágrafo. Ao anexar períodos a objetos de texto, é possível alterar o texto de diversas formas, incluindo adicionar cor, tornar o texto clicável, dimensionar o tamanho do texto e desenhar o texto de maneira personalizada. Os períodos também podem alterar as propriedades de TextPaint, desenhar em Canvas e até mesmo alterar o layout do texto.

O Android oferece vários tipos de períodos que abrangem uma variedade de padrões comuns de estilo de texto. Você também pode criar seus próprios períodos para aplicar estilos personalizados.

Criar e aplicar um período

Para criar um período, você pode usar uma das classes listadas na tabela abaixo. Cada classe é diferente, dependendo de se o texto é mutável, se a marcação de texto é mutável e da estrutura de dados subjacente que contém os dados do período:

Classe Texto mutável Marcação mutável Estrutura de dados
SpannedString Não Não Matriz linear
SpannableString Não Sim Matriz linear
SpannableStringBuilder Sim Sim Árvore de intervalo

Veja como decidir qual usar:

  • Caso não pretenda modificar o texto ou a marcação após a criação, use SpannedString.
  • Se precisar anexar um pequeno número de períodos a um único objeto de texto e o texto for somente leitura, use SpannableString.
  • Caso precise modificar o texto após a criação e anexar períodos ao texto, use SpannableStringBuilder.
  • Se precisar anexar um grande número de períodos a um objeto de texto, independentemente do texto ser somente leitura, use SpannableStringBuilder.

Todas essas classes estendem a interface Spanned. SpannableString e SpannableStringBuilder também estendem a interface Spannable.

Para aplicar um período, chame setSpan(Object _what_, int _start_, int _end_, int _flags_) em um objeto Spannable. O parâmetro what se refere ao período a ser aplicado ao texto, enquanto os parâmetros start e end indicam a parte do texto à qual o período será aplicado.

Depois de aplicar um período, se você inserir texto dentro dos limites do período, ele se expandirá automaticamente para incluir esse texto. Ao inserir texto nos limites do período, ou seja, nos índices de start ou end, o parâmetro flags determina se o período será expandido para incluir o texto inserido. Use a sinalização Spannable.SPAN_EXCLUSIVE_INCLUSIVE para incluir e Spannable.SPAN_EXCLUSIVE_EXCLUSIVE para excluir o texto inserido.

O exemplo abaixo mostra como vincular um ForegroundColorSpan a uma 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 estilizado com ForegroundColorSpan.

Como o período foi definido usando Spannable.SPAN_EXCLUSIVE_INCLUSIVE, ele se expande para incluir o texto inserido nos limites do período, conforme mostrado no exemplo abaixo:

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. O período se expande para incluir texto adicional ao usar Spannable.SPAN_EXCLUSIVE_INCLUSIVE.

Você pode anexar vários períodos ao mesmo texto. O exemplo abaixo mostra como criar um texto em negrito e em vermelho:

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 com vários períodos: ForegroundColorSpan(Color.RED) e StyleSpan(BOLD).

Tipos de períodos do Android

O Android oferece mais de 20 tipos de períodos no pacote android.text.style. Ele categoriza os períodos de duas formas principais:

  • Como o período afeta o texto: um período pode afetar a aparência ou as métricas de texto.
  • Escopo do período: alguns períodos podem ser aplicados a caracteres individuais, enquanto outros precisam ser aplicados a um parágrafo inteiro.

Figura 4. Categorias de span: caractere e parágrafo; aparência e métrica.

As seções abaixo descrevem essas categorias em mais detalhes.

Períodos que afetam a aparência do texto

Os períodos podem afetar a aparência do texto, por exemplo, mudando a cor dele ou do plano de fundo e adicionando sublinhados ou tachados. Todos esses períodos estendem a classe CharacterStyle.

O exemplo de código abaixo mostra como aplicar um UnderlineSpan para sublinhar o 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. Sublinhar texto usando um UnderlineSpan.

Períodos que afetam apenas a aparência do texto acionam um redesenho do texto sem acionar um novo cálculo do layout. Esses períodos implementam UpdateAppearance e ampliam CharacterStyle. As subclasses de CharacterStyle definem como desenhar texto, fornecendo acesso para atualizar TextPaint.

Períodos que afetam as métricas de texto

Os períodos também podem afetar as métricas de texto, como a altura da linha e o tamanho do texto. Todos esses períodos estendem a classe MetricAffectingSpan.

O exemplo de código abaixo cria um RelativeSizeSpan que aumenta o tamanho do texto em 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. Configuração do tamanho do texto usando um RelativeSizeSpan.

Aplicar um período que afeta a métrica de texto faz com que um objeto de observação meça novamente o texto para conseguir o layout e a renderização corretos. A alteração do tamanho do texto pode fazer com que as palavras apareçam em linhas diferentes, por exemplo. A aplicação do período acima aciona uma nova medição, um novo cálculo do layout do texto e um novo desenho do texto. Esses períodos geralmente estendem a MetricAffectingSpan, que é uma classe abstrata que permite que subclasses definam como o período afeta a medição de texto fornecendo acesso a TextPaint. Como a MetricAffectingSpan estende a CharacterSpan, as subclasses afetam a aparência dos caracteres do texto.

Períodos que afetam caracteres individuais

Um período pode afetar os caracteres de um texto. Por exemplo, você pode atualizar elementos de caracteres como cor de fundo, estilo ou tamanho. Os períodos que afetam caracteres individuais estendem a classe CharacterStyle.

O exemplo de código abaixo anexa um BackgroundColorSpan a um subconjunto de caracteres no 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. Aplicação de um BackgroundColorSpan ao texto.

Períodos que afetam parágrafos

Um período também pode afetar os parágrafos de um texto, por exemplo, mudando o alinhamento ou a margem de um bloco de texto inteiro. Períodos que afetam parágrafos inteiros implementam ParagraphStyle. Ao usar esses períodos, é necessário anexá-los ao parágrafo inteiro, excluindo o caractere final de nova linha. Se você tentar aplicar um período de parágrafo a algo que não seja um parágrafo inteiro, o Android não o aplicará.

A figura 8 mostra como o Android separa os parágrafos no texto.

Figura 8. No Android, os parágrafos terminam com um caractere de nova linha ('\n').

O exemplo de código a seguir aplica um QuoteSpan a um parágrafo inteiro. Se você anexar o período a qualquer posição que não seja o início ou fim de um parágrafo, o Android não aplicará o 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. Aplicação de um QuoteSpan a um parágrafo.

Criar períodos personalizados

Caso você precise de mais funcionalidades do que as fornecidas nos períodos existentes do Android, é possível implementar um período personalizado. Ao implementar seu próprio período, será necessário decidir se o período afeta o texto em nível de caracteres ou parágrafos, bem como se afeta o layout ou a aparência do texto. Isso ajuda a determinar quais classes base podem ser estendidas e quais interfaces pode ser necessário implementar. Use a tabela abaixo para referência:

Cenário Classe ou interface
Seu período afeta os caracteres do texto. CharacterStyle
Seu período afeta os parágrafos do texto. ParagraphStyle
Seu período afeta a aparência do texto. UpdateAppearance
Seu período afeta as métricas de texto. UpdateLayout

Por exemplo, caso precise implementar um período personalizado que permita modificar o tamanho e a cor do texto, estenda RelativeSizeSpan. Como essa classe já fornece callbacks para updateDrawState e updateMeasureState, você pode substituí-los para implementar seu comportamento personalizado. O código abaixo cria um período personalizado que estende RelativeSizeSpan e substitui o callback updateDrawState para definir a cor do 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);
    }
}

Observe que esse exemplo simplesmente ilustra como criar um período personalizado. Você pode conseguir o mesmo efeito aplicando RelativeSizeSpan e ForegroundColorSpan ao texto.

Uso do período de teste

A interface Spanned permite a configuração e a recuperação de períodos a partir do texto. Ao testar, implemente um teste Android JUnit para verificar se os períodos corretos foram adicionados nos locais corretos. O exemplo de Estilo de texto contém um período que aplica a marcação a marcadores anexando BulletPointSpans ao texto. O exemplo de código abaixo mostra como testar se os marcadores aparecem conforme o esperado:

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 ver mais exemplos de testes, consulte MarkdownBuilderTest (link em inglês).

Testar a implementação de período personalizado

Ao testar períodos, verifique se o TextPaint contém as modificações esperadas e se os elementos corretos aparecem no Canvas. Por exemplo, considere a implementação de um período personalizado que inclui marcadores em um texto. O marcador tem tamanho e cor específicos e há uma lacuna entre a margem esquerda da área do drawable e o marcador.

Você pode testar o comportamento dessa classe implementando um teste AndroidJUnit para verificar o seguinte:

  • Se você aplicar o período corretamente, um marcador do tamanho e da cor especificados aparecerá na tela, e haverá espaço adequado entre a margem esquerda e o marcador.
  • Se você não aplicar o período, nenhum comportamento personalizado será exibido.

Consulte a implementação desses testes no exemplo TextStylingKotlin (link em inglês).

Você pode testar as interações de Canvas simulando a tela, passando o objeto simulado para o método drawLeadingMargin() e verificando se os métodos corretos são chamados com os parâmetros corretos, como mostrado no exemplo abaixo:

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

Você pode encontrar mais testes de períodos em BulletPointSpanTest (link em inglês).

Práticas recomendadas para o uso de períodos

Há várias formas eficientes de configurar texto em um TextView com relação à memória, dependendo das suas necessidades.

Anexar ou remover um período sem alterar o texto subjacente

TextView.setText() contém várias sobrecargas que processam períodos de modos diferentes. Por exemplo, você pode definir um objeto de texto Spannable com o seguinte código:

Kotlin

textView.setText(spannableObject)

Java

textView.setText(spannableObject);

Ao chamar essa sobrecarga de setText(), o TextView cria uma cópia do Spannable como um SpannedString e o mantém na memória como um CharSequence. Isso significa que o texto e os períodos ficam imutáveis. Portanto, quando você precisar atualizar o texto ou os períodos, será necessário criar um novo objeto Spannable e chamar setText() novamente, o que também acionará uma nova medição e um novo desenho do layout.

Para indicar que os períodos precisam ser mutáveis, você pode usar setText(texto CharSequence, tipo TextView.BufferType), como mostrado no exemplo a seguir:

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

Nesse exemplo, devido ao parâmetro BufferType.SPANNABLE, o TextView cria um SpannableString, e o objeto CharSequence mantido pelo TextView passa a ter marcação mutável e texto imutável. Para atualizar o período, você pode recuperar o texto como Spannable e, então, atualizar os períodos conforme necessário.

Quando você anexa, desanexa ou reposiciona períodos, o TextView é atualizado automaticamente para refletir a mudança no texto. No entanto, se você mudar um atributo interno de um período existente, também precisará chamar invalidate() se estiver fazendo mudanças na aparência ou requestLayout() se estiver fazendo alterações na métrica.

Configurar texto em um TextView várias vezes

Em alguns casos, como ao usar um RecyclerView.ViewHolder, você pode reutilizar um TextView e configurar o texto várias vezes. Por padrão, independentemente de você definir BufferType, o TextView cria uma cópia do objeto CharSequence e a mantém na memória. Isso garante que todas as atualizações de TextView sejam intencionais. Não é possível simplesmente atualizar o objeto CharSequence original para atualizar o texto. Isso significa que sempre que você definir um novo texto, o TextView criará um novo objeto.

Caso você queira ter mais controle sobre esse processo e evitar a criação de objetos extras, pode implementar seu próprio Spannable.Factory e substituir newSpannable(). Em vez de criar um novo objeto de texto, você pode simplesmente transmitir e retornar o CharSequence existente como um Spannable, conforme demonstrado abaixo:

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

É necessário usar textView.setText(spannableObject, BufferType.SPANNABLE) ao configurar o texto. Caso contrário, o CharSequence de origem será criado como uma instância de Spanned e não poderá ser transmitido para Spannable, fazendo com que newSpannable() gere uma ClassCastException.

Após substituir newSpannable(), é necessário instruir o TextView a usar o novo Factory:

Kotlin

textView.setSpannableFactory(spannableFactory)

Java

textView.setSpannableFactory(spannableFactory);

Configure o objeto Spannable.Factory uma vez logo depois de receber uma referência para seu TextView. Se você estiver usando um RecyclerView, defina o objeto Factory quando inflar suas visualizações pela primeira vez. Isso evitará a criação de objetos extras quando RecyclerView vincular um novo item ao seu ViewHolder.

Mudar atributos de período interno

Se você precisar mudar apenas um atributo interno de um período mutável, como a cor de marcador em um período de marcador personalizado, evite a sobrecarga que chamar setText() várias vezes gera mantendo uma referência ao período quando ele for criado. Quando for necessário modificar o período, você poderá modificar a referência e chamar invalidate() ou requestLayout() no TextView, dependendo do tipo de atributo alterado.

No exemplo de código abaixo, uma implementação padrão de marcador personalizado tem cor vermelha, que muda para cinza quando se clica em um botão:

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

Usar as funções de extensão do Android KTX

O Android KTX também contém funções de extensão que facilitam ainda mais o trabalho com períodos. Para saber mais, consulte a documentação do pacote androidx.core.text.