Engenheiro de software sênior
A jornada para uma edição de texto confiável no Compose culminou na reescrita completa do BasicTextField. Essa mudança para uma arquitetura baseada em buffer eliminou as disputas de IME de uma vez por todas. Nesta sessão, vamos explicar por que a sincronização de entrada legada precisou ser removida e como o TextFieldState mantém as atualizações do teclado sincronizadas. Em seguida, vamos fazer uma investigação na edição formatada para explorar a mecânica do TrackedRange, os custos de desempenho dos blocos de edição e os desafios reais por trás dos limites de estilo, antes de dar uma olhada na renderização do Markdown.
TextFieldState substitui as sobrecargas baseadas em callback no Compose. Ele é atualizado de forma síncrona com a digitação do usuário, eliminando problemas de tempo com teclados de software e recomposições desnecessárias.TextFieldBuffer. É possível interceptar edições antes de confirmá-las ou formatar o texto de exibição sem calcular manualmente os mapeamentos de deslocamento.TextFieldState depende das necessidades da sua arquitetura. Você pode mantê-lo localmente em um elemento combinável ou armazená-lo diretamente em um ViewModel para acesso à lógica de negócios.TextFieldBuffer. É possível aplicar estilos com opções personalizáveis de ExpandPolicy, e os intervalos de estilo atuais são atualizados automaticamente conforme os usuários editam.Transcrição
Olá a todos!
Agradecemos sua participação na nossa palestra.
Meu nome é Anastasia, e este é meu colega Haoyu.
Hoje, vamos mostrar como criar uma experiência confiável de edição de texto.
Seja para criar uma tela de login simples, uma barra de pesquisa que mostra resultados enquanto o usuário digita ou um app de mensagens complexo, os campos de texto são fundamentais para a interação dos usuários com seus apps Android.
A maioria de vocês provavelmente já usa campos de texto do Material Design ou campos de texto delineados.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Há também um BasicTextField para o caso de você estar criando um sistema de design próprio ou precisar de uma caixa de edição simples.
BasicTextField(state = rememberTextFieldState())Há também o SecureTextField, que foi projetado especificamente para inserir senhas.
SecureTextField(state = rememberTextFieldState())E BasicTextField é um componente sem estilo que, na verdade, alimenta todos os outros campos de texto no Compose.
Nos últimos anos, você deve ter notado que temos dois formatos de API em paralelo: um que usa o valor e o callback de mudança de valor e outro que usa o objeto TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Então, você pode se perguntar qual deles recomendamos.
Recomendamos usar a sobrecarga TextFieldState.
Nesta palestra, vamos discutir os benefícios em termos de API, recursos e desempenho.
Ele é estável desde o Compose 1.7.
Quanto às sobrecargas de callback, estamos descontinuando-as gradualmente no Compose 1.13 em BasicTextField e wrappers do Material Design.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitIsso significa que não vamos adicionar novos recursos nem corrigir bugs, a menos que sejam problemas críticos de segurança ou acessibilidade.
O valor sem estado na sobrecarga de mudança de valor funcionou muito bem para entradas síncronas.
Mas, assim que você o pareou com streams assíncronos, por exemplo, StateFlow, ele criou problemas de tempo com o IME ou, como termo técnico, para o teclado de software.
Isso causou caracteres perdidos, caracteres duplicados e saltos de cursor, como você pode ver nesta gravação.
Para corrigir isso de forma fundamental, introduzimos o TextFieldState.
Ele é atualizado de forma síncrona quando o usuário digita, sem esperar por essas idas e voltas assíncronas, que é exatamente o que o IME precisava.
TextFieldState, além do texto somente leitura, também tem outras propriedades que criam uma sessão de edição ativa.
Isso inclui seleção, estado de desfazer/refazer, além de estilos de span e de parágrafo para edição de texto formatado. Vamos falar mais sobre isso mais adiante.
As propriedades internas do TextFieldState são apoiadas pelo sistema de snapshot do Compose.
Isso significa que, quando você lê esse texto ou seleção do objeto TextFieldState em algum lugar no Compose, o escopo específico de onde você o lê é inscrito nas atualizações.
Ele permite que BasicTextField leia apenas o estado nas fases de layout ou exibição e, junto com algumas, elimina a recomposição desnecessária quando o usuário digita.
E, junto com outras mudanças, ele melhorou significativamente o desempenho da sobrecarga de TextFieldState.
Se o texto do TextFieldState é uma propriedade somente leitura, como fazer mudanças programáticas no campo de entrada?
É aí que o TextFieldBuffer é útil.
É um objeto mutável que oferece APIs para fazer mudanças.
Portanto, quando você quiser fazer a mudança programática, chame a função de edição do TextFieldState.
Dentro desse lambda, você recebe um TextFieldBuffer.
E o TextFieldBuffer expõe várias funções que permitem inserir, substituir e excluir caracteres conforme necessário.
Uma dica rápida sobre a edição é evitar chamá-la várias vezes seguidas.
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }Em vez disso, agrupe todas as operações em uma única chamada de edição.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}Dessa forma, o Compose vai criar apenas uma instância do TextFieldBuffer, modificá-la no local e sincronizá-la com o IME e o sistema de snapshot uma vez.
Outra dica é que, para as operações em lote mais comuns, temos funções de extensão no TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Portanto, não é necessário pensar em agrupar essas operações em lote.
Basta chamar uma delas.
Por exemplo, clearText e setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}E como filtrar a entrada do usuário?
Por exemplo, aplicar um limite de caracteres ou aceitar apenas dígitos.
Na API antiga, a filtragem no onValueChange era executada depois que o texto já havia sido confirmado.
onValueChange { newString ->
// full diffing new String vs old String
}Por isso, rejeitar ou alterar essa entrada meio que entrava em conflito com o teclado.
Além disso, o callback onValueChange só fornecia a string final.
Para entender o que mudou, você precisava fazer a comparação de strings completas.
Em vez disso, na nova sobrecarga, temos a transformação de entrada.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Assim, ela intercepta a entrada do usuário antes que ela chegue ao estado do campo de texto e oferece as mesmas APIs TextFieldBuffer.
É assim que você implementa a filtragem somente de dígitos com a transformação de entrada. Basicamente, você só precisa pegar as mudanças.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Você verifica se há caracteres que não são dígitos e, se houver, rejeita a entrada chamando revertAllChanges nesse TextFieldBuffer.
E o principal recurso dessa transformação de entrada é o ChangeList.
Assim, em vez de comparar as strings completas, você pode inspecionar os intervalos de edição exatos e, em seguida, modificá-los no local ou rejeitar a entrada completamente, se necessário.
O Compose vai manter o teclado e o cursor sincronizados automaticamente para você.
Por fim, e se você quiser fazer a formatação visual da entrada?
Por exemplo, você quer adicionar parênteses ou traços a um Número do cartão de crédito ou de telefone.
Nossa!
E você não quer armazenar esses dados no seu banco de dados, certo?
Anteriormente, você usou a API de transformação visual.
Nesse caso, você precisava implementar um mapeamento bidirecional da entrada bruta para a saída visual e vice-versa.
E era muito difícil fazer isso direito, o que geralmente levava a exceções de tempo de execução.
Agora temos uma transformação de saída e, de novo, nos bastidores, ela é alimentada pelo mesmo TextFieldBuffer, então você tem praticamente a mesma API.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Neste exemplo de número de telefone, para inserir esses traços em um índice específico, basta chamar a função de inserção do TextFieldBuffer, e o sistema vai calcular o mapeamento de deslocamento bidirecional para você.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Isso elimina a matemática manual e reduz significativamente o número de falhas.
Agora, vamos para a parte mais importante: como migrar e onde elevar esse TextFieldState?
Na prática, quase todos os casos de uso se enquadram em uma dessas categorias que vou mencionar.
E quando não é, bem, geralmente significa que você tem uma arquitetura não convencional, mas esses exemplos ainda vão orientar você na migração.
O caso mais simples é manter o TextFieldState dentro do elemento combinável que contém o campo de texto.
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}Isso acontece quando o campo de texto é um campo de entrada independente, como um campo de entrada de código promocional, e você só precisa acessar esse texto quando o usuário, por exemplo, pressiona o botão de envio, certo?
Assim, o modelo de visualização não precisa interceptar a entrada do usuário em cada mudança.
Basta chamar rememberTextFieldState localmente, e o rememberTextFieldState também ajuda na restauração do estado após o encerramento do processo.
Em seguida, use a transformação de entrada para filtrar e receber o texto desse estado, o objeto TextFieldState, e, quando o usuário enviar, transmita-o pelo lambda.
E se elementos irmãos da interface, como um contador de caracteres ou um botão de envio, também precisarem ler ou mudar o texto?
Isso nos leva a um caso em que você eleva o objeto TextFieldState para uma função combinável comum, uma função combinável ancestral comum.
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}Como o TextFieldState é apoiado pelo sistema de snapshot, transmiti-lo pelo ancestral não aciona a recomposição dos pais.
Somente os lugares em que você realmente lê o texto do estado serão atualizados.
Ok.
Portanto, para casos de uso de interface complexos em que você precisa que o TextFieldState e as transformações de entrada e saída coordenem o estado internamente, em vez de elevá-los separadamente e tentar conectá-los na função combinável, use um objeto detentor de estado.
@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}Imagine que você quer lidar com uma hashtag inline.
TextFieldState contém um texto bruto e a posição do cursor.
A transformação de entrada é responsável por detectar uma hashtag e também pode fazer a exclusão atômica de tags.
A transformação de saída adicionaria mais estilo visual e também poderia ajudar a excluir a hashtag.
E todos eles são agrupados em um único objeto detentor de estado, que chamamos de TagEditorState.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}E, em seguida, todos podem reutilizar os cálculos intermediários, como tagRanges neste caso.
Em seguida, basta transmitir esse objeto detentor de estado para o elemento combinável.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Outro caso de uso é quando a lógica de negócios precisa acessar esse texto para pesquisa enquanto você digita ou para alguma validação complexa. Nesse caso, eleve o campo de texto para o ViewModel.
É importante não colocar um objeto de campo de texto mutável no estado da IU da classe de dados imutável, porque ele é copiado com um método de cópia e emitido em StateFlow a cada mudança.
data class FormUiState(val username: TextFieldState)TextFieldState é um objeto mutável de longa duração, apoiado pelo sistema de snapshot, e o objeto de referência dele nunca muda.
Portanto, seu StateFlow nem saberá que o usuário está digitando no momento, porque a referência permanece a mesma.
E pior ainda, se você criar uma nova instância do estado da interface e estiver alocando um novo TextFieldState, o campo de entrada vai perder todo o estado.
Isso inclui a seleção, a posição do cursor ou o histórico de desfazer/refazer.
Em vez disso, torne TextFieldState uma propriedade diretamente no ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}E não se preocupe com a regra de não ter IU no ViewModel aqui, porque, nesse caso, o TextFieldState não faz referência ao objeto de visualização ou contexto do Android nem ao ciclo de vida do Compose.
É apenas um objeto detentor de estado puro com suporte de snapshot.
Portanto, nesse caso, não há diferença conceitual em relação ao StateFlow.
E uma coisa para lembrar é que o ViewModel não tem rememberSaveable. Portanto, é necessário conectar o TextFieldState.Saver para que o campo de texto sobreviva ao encerramento do processo e restaure o estado.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}E se a equipe tiver regras de módulo rígidas em que os ViewModels só existem em módulos Kotlin puros e não for possível usar essa dependência do compose-foundation?
Ou, por exemplo, você está migrando uma tela legada complexa e não pode atualizar ou refatorar o estado da interface hoje?
Nesse caso, use a abordagem de conformidade.
Portanto, mantenha o TextFieldState no elemento combinável como uma única fonte de verdade para esse campo de texto.
Mas, em seguida, você usa LaunchedEffect com um snapshotFlow para enviar as mudanças de texto de volta para o ViewModel.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}E o ViewModel permanece sem o Compose. Como as propriedades de estado da interface não são enviadas de volta ao campo de texto a cada mudança, você ainda evita esses bugs de ida e volta assíncronos.
Sim, e cuidado com uma armadilha comum aqui.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}Na maioria dos casos, é melhor omitir o primeiro evento.
Isso evita consultas de pesquisa duplicadas desnecessariamente ou que o ViewModel seja substituído pelo valor inicial.
Se a lógica precisar retornar essas mudanças para o estado, por exemplo, quando o usuário pressionar o botão de envio e você quiser limpar o texto, não será possível definir uma string vazia para o ViewModel do ViewModel.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}Em vez disso, é necessário emitir um evento de texto claro. Em seguida, a função combinável que contém esse objeto TextFieldState vai chamar clearText nele.
Para ajudar você ainda mais com a transição, escrevemos este guia de migração.
Acesse o documento neste link.
Também estamos trabalhando em uma API adicional para facilitar a migração e a observação de mudanças gerais do TextFieldState. Fique de olho.
E tudo o que abordamos até agora, TextFieldState, TextFieldBuffer, transformação de entrada e saída, não foi criado apenas para entrada de texto simples.
Essa é a base do nosso mais novo conjunto de APIs para edição de texto formatado.
Agora, Haoyu vai falar mais sobre isso.
Certo.
Nossa equipe agradece.
Olá!
Em seguida, vou falar sobre as novas APIs que introduzimos para edição de texto formatado.
Antes de falarmos sobre as novas APIs, vamos dar um passo atrás e ver como a estilização funciona em textos estáticos.
A maioria de vocês já conhece bem a AnnotatedString.
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)É a maneira padrão de estilizar um texto estático.
Usamos buildAnnotatedString e chamamos addStyle para aplicar SpanStyle ou ParagraphStyle em determinados intervalos.
Isso funciona bem para o texto estático.
Quando analisamos a edição de texto formatado, nossa primeira ideia foi: podemos tornar a AnnotatedString editável?
Mas logo percebemos que não é tão simples assim.
AnnotatedString é uma estrutura de dados imutável.
Mas, no mundo da edição, o usuário está sempre digitando.
E a cada tecla pressionada, precisamos alocar um novo objeto AnnotatedString.
E em cada AnnotatedString, há muitos AnnotatedString.Range.
Portanto, esses objetos também precisam ser recriados.
Ele não é bem escalonado e causa uma sobrecarga de alocação significativa.
Confira este exemplo.
Quando inserimos a palavra no meio, todos os estilos depois dela mudam de intervalo e são realocados.
Isso claramente não funciona quando você tem muitos estilos.
Por isso, introduzimos APIs de estilo dedicadas no TextFieldBuffer que a Anastasia apresentou antes.
Elas são muito semelhantes às APIs de estilo em AnnotatedString.
Vamos entender como eles funcionam.
Assim como AnnotatedString.Builder, o TextFieldBuffer agora tem addStyle para aplicar SpanStyle ou ParagraphStyle a um intervalo.
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>Também é possível transmitir um parâmetro ExpandPolicy, que controla como o estilo se comporta quando o texto é digitado nos limites do estilo.
Vamos falar sobre isso em breve.
Primeiro, vamos ver como funciona o rastreamento de estilo.
val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}Depois de adicionar um estilo ao TextFieldBuffer, o intervalo dele é atualizado automaticamente conforme o texto é editado.
Neste exemplo, começamos deixando um texto em negrito.
Quando você insere texto antes dele, o intervalo de estilo é deslocado para frente.
E, quando você insere texto dentro dele, o intervalo de texto se expande.
E, quando excluímos o texto dentro do intervalo, o intervalo de estilo diminui de acordo.
Se todo o texto dentro do intervalo de estilo for excluído, o estilo será recolhido e removido automaticamente do buffer.
O que acontece quando o texto é inserido bem nos limites de um intervalo de estilo?
O texto recém-inserido é coberto pelo estilo ou não?
Para que você possa controlar isso, lançamos as APIs ExpandPolicy.
Vamos analisar cada uma delas.
O primeiro é ExpandPolicy.InsideOnly.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")Nesse modo, a inserção de texto no limite não expande os intervalos de estilo.
Aqui, primeiro aplicamos um intervalo em negrito.
Se inserirmos texto no início do intervalo em negrito, o intervalo de estilo vai mudar, mas não vai se expandir.
Da mesma forma, se inserirmos texto no final do intervalo em negrito, o intervalo de estilo também não será expandido.
Confira uma demonstração de como ele funciona em um editor.
Em seguida, temos ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Como o nome sugere, inserir texto no início do intervalo expande o intervalo de estilo.
Portanto, quando inserimos texto logo antes, o intervalo de estilo se expande para cobrir o texto recém-inserido.
Aqui está outra demonstração mostrando como funciona.
Em terceiro lugar, temos ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Portanto, estamos inserindo texto no final do intervalo para expandir o intervalo de estilo.
Essa também é a configuração mais comum para os editores.
Confira uma demonstração de como funciona.
Por fim, temos ExpandPolicy.AtBoth, que expande o estilo quando o texto é inserido no início e no final do intervalo.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}Então, outra demonstração para isso.
Até agora, abordamos todas as APIs de aplicação de estilo.
Além disso, também introduzimos novas APIs de consulta, como getSpanStyles e getParagraphStyles, para buscar os estilos aplicados no momento em um determinado intervalo.
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>Confira duas versões das APIs de consulta.
No TextFieldState, as APIs de consulta de estilo são somente leitura.
Eles retornam uma lista de objetos AnnotatedString.Range imutáveis.
No TextFieldBuffer, getSpanStyles retornou uma lista de objetos TrackedRange recém-introduzidos, que vou apresentar mais tarde.
Portanto, todas as consultas de estilo consideram a ordem.
Eles retornam os intervalos de estilo exatamente na ordem em que o estilo é aplicado.
Assim, você pode aplicar, assim como o AnnotatedString, outro estilo sobre um atual para substituir os anteriores.
Em seguida, temos a API TrackedRange.
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): BooleanOs objetos TrackedRange são retornados pelas APIs addStyle ou getSpanStyles em TextFieldBuffer.
Pense nisso como um identificador ativo do intervalo de estilo no buffer.
Você pode usá-lo para atualizar o intervalo de texto, o ExpandPolicy ou o próprio estilo no local.
Ele também tem uma propriedade isValid para verificar se o intervalo de estilo ainda está no TextFieldBuffer.
Isso ocorre porque o estilo pode ser removido se for recolhido durante a edição ou se for removido deliberadamente pelo removeStyle.
Agora vamos ver como essas APIs se unem em casos de uso reais.
O primeiro caso de uso é criar um editor WYSIWYG (What You See Is What You Get).
Neste exemplo, implementamos o recurso de botão em negrito.
Clicar no botão de negrito ativa ou desativa o estilo de negrito no texto selecionado.
Agora vamos conferir o código.
Portanto, quando o usuário toca no botão de negrito, primeiro chamamos getSpanStyles em uma seleção para encontrar todos os estilos sobrepostos.
fun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}Em seguida, verificamos se toda a seleção já está em negrito ou não.
Aqui, a palavra selecionada não está em negrito.
Como não está em negrito, addStyle ao intervalo selecionado.
Passamos ExpandPolicy.AtEnd para que o usuário possa continuar digitando em negrito logo após essa palavra.
Agora, vamos ver o que acontece se o intervalo selecionado já estiver em negrito.
fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}Nesse caso, precisamos limpar o estilo em negrito do intervalo selecionado.
Aqui, a palavra selecionada pelo usuário está dentro da frase mais longa em negrito.
Como o estilo em negrito se estende antes da seleção, precisamos atualizar o intervalo de estilo, deixando a palavra selecionada sem negrito.
Para isso, podemos usar uma API TrackedRange.
Nesse caso específico, em vez de excluir esse estilo e recriá-lo, podemos ajustar o intervalo do período em negrito para que ele termine antes da seleção no índice 10.
Claro que essa é uma implementação muito simplificada.
Para uma demonstração completa, confira nosso repositório de exemplos do Compose.
Agora, vamos conferir um estilo baseado em padrões.
Nesta demonstração, quando o usuário digita asteriscos de Markdown ao redor de uma palavra, o editor converte automaticamente em texto em negrito.
Agora, vamos ver como isso é implementado usando InputTransformation.
object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}No filtro de entrada de transformação, encontramos todos os padrões correspondentes no botão.
E, para cada correspondência, extraímos o conteúdo dentro dos asteriscos duplos.
Neste exemplo, é a palavra "fox".
Em seguida, removemos os asteriscos e substituímos o padrão correspondente pelo conteúdo dele.
Por fim, chamamos addStyle para aplicar o estilo em negrito ao texto do conteúdo.
Lembre-se de que o InputTransformation vai enviar o estado final do campo de texto.
Isso significa que o estilo em negrito é aplicado ao estado de forma persistente.
Por fim, vamos analisar o exemplo de destaque de pesquisa.
Neste exemplo, quando o usuário digita uma consulta de pesquisa, as palavras correspondentes nos campos de texto são destacadas, sem modificar o estado do campo de texto.
Agora, vamos ver como isso é implementado no OutputTransformation.
Ao destacar uma palavra de pesquisa, nunca mude o estado do texto.
E o OutputTransformation é o lugar perfeito para implementar esse caso de uso.
Vamos conferir a implementação real.
Primeiro, pesquisamos as palavras de destaque e encontramos todas as correspondências.
class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}A consulta é um estado mutável.
Isso ocorre porque o OutputTransformation reconhece snapshots.
Quando esses estados mutáveis são atualizados, o OutputTransformation é reaplicado automaticamente.
Em seguida, quando fazemos um loop em todas as correspondências e chamamos addStyle para desenhar os destaques amarelos sobre o intervalo de correspondência.
Novamente, OutputTransformation só muda o texto visual, e os estados do texto original permanecem inalterados.
Ok, esses são todos os casos de uso que temos.
Só para lembrar, todas as APIs mostradas antes estão disponíveis a partir do Compose 1.12.
Para encerrar, quero compartilhar uma prévia do novo projeto em que estamos trabalhando.
É uma biblioteca de texto de marcação para o Jetpack Compose, que oferece suporte a formatos de Rich Text, como Markdown.
Um dos recursos mais interessantes que estamos oferecendo é a análise e a renderização incrementais.
Por isso, é muito adequado para edição ao vivo ou streaming de Markdown.
Aqui está um registro do nosso protótipo em ação. As áreas que desaparecem são as partes que estão sendo atualizadas na interface.
Se você tiver interesse ou um caso de uso no seu app, entre em contato com nossa equipe.
E obrigado a todos.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}