Estilizar entrada de rich text

A entrada de rich text permite que os usuários formatem o que digitam, incluindo colocar uma palavra em negrito, adicionar um título, alinhar um parágrafo ou inserir um @mention destacado. No Compose, você cria isso com base em campos de texto baseados em estado (BasicTextField e TextField). Os estilos ficam em TextFieldState junto com o texto, então eles resistem a edições, mudanças de seleção e recomposições, além de permanecerem sincronizados conforme o usuário digita, exclui e cola.

Nesta página, mostramos como aplicar, atualizar, consultar, extrair e remover estilos em um TextFieldState usando TextFieldBuffer e como controlar uma barra de ferramentas de formatação do TextFieldState.textStyles.

Aplicar estilos inline e de parágrafo

É possível aplicar formatação no nível de caracteres e de parágrafos em qualquer escopo de receptor TextFieldBuffer, incluindo TextFieldState.edit, InputTransformation.transformInput e OutputTransformation.transformOutput.

Controlar a expansão de limites com ExpandPolicy

Ao chamar addStyle, você pode transmitir um ExpandPolicy para definir como o intervalo estilizado se comporta quando o usuário insere novos caracteres diretamente no limite start ou end:

  • Inserção dentro de (start, end): sempre expande o intervalo estilizado.
  • Inserção antes de start: sempre desloca os deslocamentos start e end para a direita.
  • Inserção direta em start ou end: controlada pelo ExpandPolicy especificado.

A tabela a seguir descreve as opções de ExpandPolicy disponíveis e os casos de uso típicos:

Política Comportamento de limite Casos de uso recomendados Exemplo
ExpandPolicy.InsideOnly O texto digitado em start ou end fica fora do intervalo estilizado. Tokens discretos, como @mentions, #hashtags ou pílulas de plano de fundo, em que a digitação ao lado da tag não deve herdar a cor de fundo da tag. Animação mostrando o texto digitado no início ou no fim de um intervalo ExpandPolicy.InsideOnly sem estilo.
ExpandPolicy.AtStart O texto digitado em start herda o estilo, e o texto digitado em end permanece sem estilo. O texto digitado diretamente na frente do texto estilizado também deve adotar a mesma formatação. Animação mostrando o texto digitado no início de um intervalo ExpandPolicy.AtStart herdando o estilo, enquanto o texto digitado no final permanece sem estilo.
ExpandPolicy.AtEnd O texto digitado em end herda o estilo, e o texto digitado em start permanece sem estilo. Digitação padrão do editor de texto rich text, em que a formatação é mantida ao continuar digitando após uma palavra em negrito ou itálico. Animação mostrando o texto digitado no final de um intervalo ExpandPolicy.AtEnd herdando o estilo, enquanto o texto digitado no início permanece sem estilo.
ExpandPolicy.AtBoth O texto digitado em start ou end expande o intervalo e herda o estilo. Destaques ativos ou blocos formatados contínuos que devem crescer de qualquer borda. Animação mostrando o texto digitado no início ou no fim de um intervalo ExpandPolicy.AtBoth herdando o estilo.

Aplicar formatação de caracteres com SpanStyle

Use addStyle com um SpanStyle para aplicar formatação no nível do caractere, como cor do texto, destaque do plano de fundo, FontWeight, FontStyle ou sublinhado a um TextRange específico no buffer.

Principais comportamentos para intervalos de SpanStyle

  • Ordem de camadas: os estilos são aplicados na ordem em que são adicionados ao buffer. Se vários intervalos SpanStyle sobrepostos definirem a mesma propriedade com valores diferentes, o estilo adicionado por último será renderizado por cima.
  • Requisito de intervalo não vazio: é necessário transmitir um intervalo não recolhido em que start < end. Chamar addStyle em uma seleção recolhida de tamanho 0 não tem efeito, e transmitir índices invertidos ou fora dos limites gera uma IllegalArgumentException.

TextField(
    state = rememberTextFieldState(),
    inputTransformation = InputTransformation {
        for (i in 0 until changes.changeCount) {
            val range = changes.getRange(i)
            if (!range.collapsed) {
                addStyle(
                    spanStyle = SpanStyle(
                        fontWeight = FontWeight.ExtraBold,
                        fontStyle = FontStyle.Italic,
                        color = Color.Red,
                    ),
                    range = range,
                    expandPolicy = ExpandPolicy.AtEnd
                )
            }
        }
    }
)

Um TextField que aplica dinamicamente a formatação em negrito e itálico em vermelho conforme o usuário digita.
Figura 1. Aplicar formatação em negrito e itálico vermelho à entrada do usuário de forma dinâmica.

Aplicar formatação de bloco com ParagraphStyle

Use addStyle com um ParagraphStyle para aplicar formatação estrutural no nível do bloco, como textAlign, lineHeight, textDirection ou textIndent, a um intervalo de parágrafos.

fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) {
    val targetRange = if (state.selection.collapsed) {
        TextRange(0, state.text.length)
    } else {
        state.selection
    }
    if (targetRange.collapsed) return
    state.edit {
        // 1. Remove existing paragraph styles in this range
        getParagraphStyles(targetRange).forEach { removeStyle(it) }
        // 2. Apply the new paragraph alignment
        addStyle(
            paragraphStyle = ParagraphStyle(textAlign = alignment),
            range = targetRange,
            expandPolicy = ExpandPolicy.InsideOnly
        )
    }
}

Um TextField que alterna o alinhamento do parágrafo entre início, centro e fim usando ParagraphStyle.
Figura 2. Alternar o alinhamento do parágrafo entre início, centro e fim usando ParagraphStyle.

Armadilhas que devem ser evitadas com ParagraphStyle

Como ParagraphStyle se aplica a blocos inteiros de texto, evite usá-lo nos seguintes cenários:

  • Expansão para parágrafos adjacentes: defina ExpandPolicy com cuidado para que os intervalos ParagraphStyle adjacentes não se sobreponham após as edições do usuário. Por exemplo, se [0, 5) usar ExpandPolicy.AtEnd e [5, 10) usar ExpandPolicy.AtStart, a inserção de texto no índice 5 vai expandir os dois estilos em uma sobreposição e gerar um IllegalArgumentException.
  • Aninhamento de intervalos ParagraphStyle: embora intervalos completamente aninhados (como [0, 10) e [2, 8)) sejam válidos em AnnotatedString, evite aninhar intervalos ParagraphStyle em um campo de texto. O layout divide os intervalos aninhados em parágrafos separados, e as edições subsequentes podem transformar acidentalmente os intervalos aninhados em uma sobreposição parcial.

Atualizar um estilo no lugar com TrackedRange

Chamar addStyle com um TextRange e ExpandPolicy retorna um identificador TrackedRange, que pode ser TrackedRange<SpanStyle> ou TrackedRange<ParagraphStyle>. Um TrackedRange é uma referência dinâmica a um intervalo de estilo no TextFieldBuffer que atualiza automaticamente os deslocamentos de caracteres start e end à medida que o texto é inserido, excluído ou substituído ao redor dele.

Mutabilidade no local

Não é necessário chamar removeStyle e addStyle para atualizar um estilo atual ou ajustar os limites dele. Em um escopo TextFieldBuffer, TrackedRange fornece propriedades de extensão que atualizam o intervalo no local, preservando a ordem de camadas original em relação a outros estilos:

  • TrackedRange<SpanStyle>.spanStyle: lê ou substitui o payload SpanStyle em um TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: lê ou substitui o payload ParagraphStyle em um TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: lê ou atualiza os limites atuais de TextRange no local. Definir um intervalo recolhido, invertido ou fora dos limites gera um IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: lê ou atualiza o ExpandPolicy no local.
  • TrackedRange<*>.isValid: retorna true se o intervalo de estilo ainda estiver ativo no buffer ou false se as edições tiverem causado o colapso do comprimento para 0 ou se ele tiver sido removido.

// Trim an existing bold span in place when the selection overlaps one edge
val range = style.textRange
if (range.start < selection.min && range.end <= selection.max) {
    // Selection covers the end of the span - shrink end boundary in place
    style.textRange = TextRange(range.start, selection.min)
} else if (range.start >= selection.min && range.end > selection.max) {
    // Selection covers the start of the span - shrink start boundary in place
    style.textRange = TextRange(selection.max, range.end)
}

Confinamento de escopo e regras de ciclo de vida

Lembre-se das seguintes regras de escopo e ciclo de vida ao trabalhar com instâncias de TrackedRange:

  • Restritas a TextFieldBuffer: as propriedades de extensão TrackedRange só existem no escopo TextFieldBuffer em que o identificador foi criado ou consultado.
  • Remoção automática ao recolher: se as edições do usuário excluírem todos os caracteres cobertos por um TrackedRange, o comprimento dele será reduzido para 0, isValid retornará false e o estilo será removido do buffer.

Consultar e remover estilos

O Compose oferece duas maneiras de inspecionar estilos ativos, dependendo se você está modificando o buffer ou lendo o estado da IU:

  • Dentro de um bloco TextFieldBuffer: chame getSpanStyles e getParagraphStyles diretamente no receptor TextFieldBuffer para receber identificadores TrackedRange mutáveis.
  • Fora de um bloco de edição: leia TextFieldState.textStyles, que retorna listas AnnotatedString.Range imutáveis que refletem o estado confirmado.

Consultar estilos de interseção em TextFieldBuffer

TextFieldBuffer.getSpanStyles e TextFieldBuffer.getParagraphStyles retornam todas as instâncias de TrackedRange que se cruzam com o TextRange fornecido, ordenadas por quando foram adicionadas ao buffer.

Por exemplo, é possível consultar getSpanStyles(selection) dentro de state.edit para inspecionar ou atualizar estilos que abrangem a seleção atual do usuário:

state.edit {
    if (selection.collapsed) return@edit
    // Query all bold spans that overlap with the current selection
    val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
        it.spanStyle.fontWeight == FontWeight.Bold
    }
    // ...
}

Os intervalos de estilo usam um intervalo matemático semiaberto [start, end). O retorno de um estilo de toque depende se você consulta uma seleção ou um único ponto de cursor:

  • Para uma seleção não vazia [start, end):
    • Retornado: qualquer estilo que se sobreponha, contenha ou esteja dentro do intervalo de seleção. Por exemplo, a consulta [5, 15) corresponde a [0, 20), [8, 12) ou [0, 10).
    • Não retornados: estilos que tocam apenas as bordas externas sem sobrepor caracteres. Por exemplo, a consulta [5, 15) exclui [0, 5) e [15, 25).
  • Para um cursor recolhido [index, index]:
    • Retornado: qualquer estilo que contenha o cursor ou que comece na posição dele. Por exemplo, um cursor em 10 corresponde a [0, 20) e [10, 20).
    • Não retornado (regra de fim de toque): qualquer estilo que termine na posição do cursor. Por exemplo, um cursor em 10 exclui [0, 10).

Remover ou alternar estilos com removeStyle

Transmita um identificador TrackedRange para removeStyle para excluir esse intervalo de estilo específico do TextFieldBuffer.

removeStyle retorna true se o TrackedRange foi encontrado e removido ou false se ele já foi removido ou pertencia a um buffer diferente. Ele remove apenas o identificador TrackedRange segmentado, deixando outros estilos sobrepostos intactos.

fun toggleBold(state: TextFieldState) {
    state.edit {
        if (selection.collapsed) return@edit
        // Query all bold spans that overlap with the current selection
        val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
        if (boldStyles.isNotEmpty()) {
            // Remove or trim existing bold spans covering the selection
            for (style in boldStyles) {
                val range = style.textRange
                if (range.start >= selection.min && range.end <= selection.max) {
                    removeStyle(style)
                } else if (range.start < selection.min && range.end > selection.max) {
                    val oldEnd = range.end
                    style.textRange = TextRange(range.start, selection.min)
                    addStyle(
                        spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                        range = TextRange(selection.max, oldEnd),
                        expandPolicy = ExpandPolicy.AtEnd
                    )
                } else if (range.start < selection.min) {
                    style.textRange = TextRange(range.start, selection.min)
                } else {
                    style.textRange = TextRange(selection.max, range.end)
                }
            }
        } else {
            // No bold spans intersect the selection - apply bold
            addStyle(
                spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                range = selection,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}

Um TextField que ativa e desativa a formatação em negrito para as palavras selecionadas.
Figura 3. Alternar entre texto em negrito e sem negrito.

Extrair ou serializar rich text

Para mostrar a entrada formatada em um elemento de composição Text somente leitura ou serializá-la em um serviço de back-end, leia TextFieldState.textStyles fora de um bloco de edição.

state.textStyles.getSpanStyles e state.textStyles.getParagraphStyles retornam List<AnnotatedString.Range<SpanStyle>> e List<AnnotatedString.Range<ParagraphStyle>>, que podem ser transmitidos diretamente para o construtor AnnotatedString:

fun TextFieldState.toAnnotatedString(): AnnotatedString {
    val fullRange = TextRange(0, text.length)
    return AnnotatedString(
        text = text.toString(),
        spanStyles = textStyles.getSpanStyles(fullRange),
        paragraphStyles = textStyles.getParagraphStyles(fullRange)
    )
}

Exemplo completo no Jetchat

Para conferir uma implementação completa que combina estilos de caracteres inline (negrito, itálico, sublinhado, tachado e cor), consulte o exemplo Jetchat no GitHub:

  • RichTextState.kt: encapsula consultas de estilo TextFieldState, alternância de span, corte ou divisão entre limites de seleção e muito mais.
  • UserInput.kt: conecta RichTextState à barra de ferramentas de formatação de rich text e ao campo de entrada de mensagens.
O app de exemplo Jetchat demonstra a formatação Rich Text na barra de entrada de mensagens.
Figura 4. Edição de rich text no Jetchat.