Senior Software Engineer
Il percorso verso un editor di testo affidabile in Compose è culminato nella riscrittura completa di BasicTextField. Questo passaggio a un'architettura basata su buffer ha eliminato definitivamente le race condition dell'IME. Questa sessione spiega perché la sincronizzazione degli input legacy è stata abbandonata e come TextFieldState mantiene sincronizzati gli aggiornamenti della tastiera. Da lì, approfondiremo l'editing formattato per esplorare i meccanismi di TrackedRange, i costi di prestazioni dei blocchi di modifica e le insidie reali dietro i limiti di stile, prima di dare un'occhiata in anteprima al rendering di Markdown.
TextFieldState sostituisce gli overload basati su callback in Compose. Si aggiorna in modo sincrono con la digitazione dell'utente, eliminando i problemi di tempistica con le tastiere su schermo e le ricomposizioni non necessarie.TextFieldBuffer. Puoi intercettare le modifiche prima di eseguirne il commit o formattare il testo visualizzato senza calcolare manualmente le mappature degli offset.TextFieldState dipende dalle tue esigenze di architettura. Puoi conservarlo localmente in un composable o archiviarlo direttamente in un ViewModel per l'accesso alla logica di business.TextFieldBuffer. Puoi applicare stili con opzioni ExpandPolicy personalizzabili e gli intervalli di stile esistenti vengono aggiornati automaticamente man mano che gli utenti apportano modifiche.Trascrizione
Ciao,
Grazie per aver partecipato al nostro intervento.
Mi chiamo Anastasia e con me c'è il mio collega Haoyu.
Oggi vi mostreremo come creare un'esperienza di modifica del testo affidabile.
Che tu stia creando una semplice schermata di accesso, una barra di ricerca durante la digitazione o un'app di messaggistica complessa, i campi di testo sono al centro del modo in cui gli utenti interagiscono con le tue app per Android.
Quindi, la maggior parte di voi probabilmente utilizza già i campi di testo Material o i campi di testo con contorno.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Esiste anche un BasicTextField se stai creando un sistema di progettazione personalizzato o se hai bisogno di una semplice casella di modifica.
BasicTextField(state = rememberTextFieldState())Esiste anche SecureTextField, progettato specificamente per l'inserimento delle password.
SecureTextField(state = rememberTextFieldState())BasicTextField è un componente senza stile e, di fatto, alimenta tutti gli altri campi di testo in Compose.
Quindi, potresti aver notato che negli ultimi anni abbiamo avuto due forme di API in parallelo, una che accetta il valore e il callback di modifica del valore e l'altra che accetta l'oggetto TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Quindi, potresti chiederti quale consigliamo.
Ti consigliamo vivamente di utilizzare l'overload TextFieldState.
In questo intervento parleremo dei suoi vantaggi in termini di API, funzionalità e prestazioni.
È stabile da Compose 1.7.
Per quanto riguarda gli overload di callback, li stiamo ritirando in Compose 1.13 in BasicTextField e nei wrapper di Material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitCiò significa che non aggiungeremo nuove funzionalità né correggeremo bug, a meno che non si tratti di problemi di sicurezza critici o di accessibilità.
Il valore stateless nell'overload della modifica del valore ha funzionato molto bene per gli input sincroni.
Tuttavia, non appena lo hai accoppiato con stream asincroni, ad esempio StateFlow, ha creato problemi di tempistica con l'IME o, in termini tecnici, con la tastiera su schermo.
Questo causava la perdita di caratteri, la duplicazione di caratteri e salti del cursore, come puoi vedere in questa registrazione.
Per risolvere questo problema alla radice, abbiamo introdotto TextFieldState.
Si aggiorna in modo sincrono quando l'utente digita, senza attendere i round trip asincroni, che è esattamente ciò di cui l'IME aveva bisogno.
TextFieldState, oltre al testo di sola lettura, ha anche altre proprietà che creano una sessione di modifica attiva.
ovvero selezione, stato di annullamento e ripetizione, nonché stili di span e paragrafo per la modifica del testo formattato, di cui parleremo più avanti in questo intervento.
Le proprietà interne di TextFieldState sono supportate dal sistema di snapshot di Compose.
Ciò significa che quando leggi il testo o la selezione dall'oggetto TextFieldState in un punto qualsiasi di Compose, l'ambito specifico da cui leggi è iscritto agli aggiornamenti.
Consente a BasicTextField di leggere lo stato solo nelle fasi di layout o disegno e, insieme ad altri, elimina la ricomposizione non necessaria quando l'utente digita.
Insieme ad altre modifiche, ha portato a un miglioramento significativo delle prestazioni per l'overload di TextFieldState.
Ora, se il testo di TextFieldState è una proprietà di sola lettura, come fai ad apportare modifiche programmatiche al campo di immissione?
Ed è qui che entra in gioco TextFieldBuffer.
Si tratta di un oggetto modificabile che fornisce API per apportare modifiche.
Quindi, quando vuoi apportare la modifica in modo programmatico, chiami la funzione di modifica di TextFieldState.
All'interno di questa lambda, ottieni un TextFieldBuffer.
Inoltre, TextFieldBuffer espone diverse funzioni che ti consentono di inserire, sostituire ed eliminare i caratteri in base alle tue esigenze.
Un suggerimento rapido per la modifica è evitare di chiamarli più volte di seguito.
// 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) }Invece, raggruppa tutte le operazioni in un'unica chiamata di modifica.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}In questo modo, Compose creerà una singola istanza di TextFieldBuffer, la muterà in loco e la sincronizzerà con l'IME e il sistema di snapshot una sola volta.
Un altro suggerimento è che per le operazioni batch più comuni di cui avrai bisogno, abbiamo effettivamente funzioni di estensione su TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Quindi, non devi preoccuparti di raggruppare queste operazioni in batch.
Basta chiamare uno di questi.
Un esempio potrebbe essere clearText e setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}E per quanto riguarda il filtro dell'input utente?
Ad esempio, l'applicazione di un limite di caratteri o l'accettazione solo di cifre.
Nella vecchia API, il filtro in onValueChange veniva eseguito dopo che il testo era già stato inserito.
onValueChange { newString ->
// full diffing new String vs old String
}Quindi, rifiutare o modificare l'input era un po' come lottare con la tastiera.
Inoltre, il callback onValueChange ti ha fornito solo la stringa finale.
Quindi, per capire cosa era cambiato, dovevi eseguire il diffing dell'intera stringa.
Invece, nel nuovo overload, abbiamo la trasformazione dell'input.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Quindi intercetta l'input dell'utente prima che raggiunga lo stato del campo di testo e ti fornisce le stesse API TextFieldBuffer.
Ecco come implementare il filtro solo per i numeri con la trasformazione dell'input. In pratica, devi solo applicare le modifiche.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Controlli se sono presenti caratteri non numerici e, in caso affermativo, rifiuti l'input chiamando revertAllChanges su quel TextFieldBuffer.
La funzionalità chiave di questa trasformazione dell'input è ChangeList.
Quindi, invece di confrontare le stringhe complete, ti consente di ispezionare gli intervalli di modifica esatti e poi di modificarli in loco o di rifiutare del tutto l'input, se necessario.
Compose manterrà automaticamente sincronizzati la tastiera e il cursore.
Infine, cosa succede se vuoi formattare visivamente l'input?
Ad esempio, vuoi aggiungere parentesi o trattini a un numero di carta di credito o di telefono.
Ah, sì.
E non vuoi archiviarli nel tuo database, giusto?
In precedenza, hai utilizzato l'API di trasformazione visiva.
In questo caso, hai dovuto implementare una mappatura bidirezionale dall'input non elaborato all'output visivo e viceversa.
Inoltre, era molto difficile farlo correttamente, il che spesso portava a eccezioni di runtime.
Ora abbiamo una trasformazione dell'output e, di nuovo, dietro le quinte, è basata sullo stesso TextFieldBuffer, quindi ottieni praticamente la stessa API.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Quindi, in questo esempio di numero di telefono, per inserire i trattini in un indice specifico, basta chiamare la funzione di inserimento di TextFieldBuffer e il sistema calcolerà la mappatura dell'offset bidirezionale per te.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}In questo modo si eliminano i calcoli manuali e si riduce notevolmente il numero di arresti anomali.
Ora passiamo alla parte più importante: come eseguire la migrazione e dove issare la bandiera TextFieldState?
In pratica, quasi tutti i casi d'uso rientrano in una delle categorie di cui parlerò.
In caso contrario, di solito significa che hai un'architettura non convenzionale, ma questi esempi ti guideranno comunque nella migrazione.
Ok, quindi il caso più semplice è mantenere TextFieldState direttamente all'interno del composable in cui si trova il campo di testo.
@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()) }) { }
)
}Questo accade quando il campo di testo è un campo di immissione autonomo, ad esempio un campo di immissione del codice promozionale, e devi accedere a quel testo solo quando l'utente, ad esempio, preme il pulsante di invio, giusto?
In questo modo, il modello di visualizzazione non deve intercettare l'input dell'utente a ogni modifica.
Quindi, basta chiamare rememberTextFieldState localmente e rememberTextFieldState ti aiuta anche con il ripristino dello stato dopo l'interruzione del processo.
Poi utilizzi la trasformazione dell'input per il filtro e ottieni il testo da quello stato, l'oggetto TextFieldState, e quando l'utente invia, lo passi tramite la funzione lambda.
Ok, cosa succede se anche gli elementi UI di pari livello, come un contatore di caratteri o un pulsante di invio, devono leggere il testo o modificarlo?
Questo ci porta a un caso in cui l'oggetto TextFieldState viene spostato in una funzione componibile comune, una funzione componibile di un predecessore comune.
@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)
}
}Inoltre, poiché TextFieldState è supportato dal sistema di snapshot, il passaggio attraverso l'antenato non attiva la ricomposizione degli elementi principali.
Verranno aggiornati solo i punti in cui leggi effettivamente il testo dello stato.
Ok.
Pertanto, per i casi d'uso di UI complesse in cui è necessario che TextFieldState, la trasformazione di input e output coordinino lo stato internamente, anziché eseguirne l'hoisting separatamente e tentare di collegarli insieme nella funzione componibile, utilizza un oggetto contenitore di stato.
@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,
)
}Immagina di voler gestire un hashtag in linea.
TextFieldState contiene un testo non elaborato e la posizione del cursore.
La trasformazione dell'input è responsabile del rilevamento di un hashtag e, inoltre, consente di eseguire l'eliminazione atomica dei tag.
La trasformazione dell'output aggiungerebbe uno stile visivo aggiuntivo e potrebbe anche aiutarti a eliminare l'hashtag.
E sono tutti raggruppati in un unico oggetto contenitore di stato, che chiamerò 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) { ... }
}Inoltre, possono riutilizzare i calcoli intermedi, come tagRanges in questo caso.
Poi, devi semplicemente passare questo oggetto contenitore di stato al composable.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Ok, un altro caso d'uso è quando la logica di business ha effettivamente bisogno di accedere a quel testo per la ricerca durante la digitazione o per una convalida complessa, quindi sposta il campo di testo nel tuo ViewModel.
È importante non inserire un oggetto campo di testo modificabile nello stato della UI della classe di dati immutabile perché viene copiato con un metodo di copia e poi emesso in StateFlow a ogni modifica.
data class FormUiState(val username: TextFieldState)TextFieldState è un oggetto mutabile di lunga durata supportato dal sistema di snapshot e il suo oggetto di riferimento non cambia mai.
Pertanto, il tuo StateFlow non saprà nemmeno che l'utente sta digitando in quel momento perché il riferimento rimane lo stesso.
Ancora peggio, se crei una nuova istanza dello stato della UI e allochi un nuovo TextFieldState, il campo di immissione perderà tutto il suo stato.
Ad esempio, la selezione, la posizione del cursore o la cronologia di annullamento/ripetizione.
Quindi, rendi TextFieldState una proprietà direttamente sul tuo ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}E non preoccuparti della regola "no UI" in ViewModel, perché in questo caso TextFieldState non fa riferimento all'oggetto Android View o Context o al ciclo di vita di Compose.
È solo un oggetto contenitore di stato puro supportato da snapshot.
In questo caso, concettualmente non è diverso da StateFlow.
Una cosa da ricordare è che ViewModel non ha rememberSaveable, quindi devi collegare TextFieldState.Saver per il campo di testo per sopravvivere all'interruzione del processo e ripristinare lo stato.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Ora, cosa succede se il tuo team ha regole rigide per i moduli in cui i ViewModel risiedono solo in moduli Kotlin puri e non puoi importare la dipendenza compose-foundation?
O, ad esempio, stai eseguendo la migrazione di una schermata legacy complessa e non puoi aggiornare o rifattorizzare lo stato della UI oggi?
In questo caso, utilizza l'approccio conforme.
Pertanto, mantieni TextFieldState nel composable come Single Source Of Truth per quel campo di testo.
Tuttavia, utilizzi LaunchedEffect con un snapshotFlow per eseguire il push delle modifiche al testo nel ViewModel esistente.
@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)
}Inoltre, ViewModel rimane privo di Compose e, poiché le proprietà dello stato della UI non vengono reinserite nel campo di testo a ogni modifica, eviti comunque i bug di andata e ritorno asincroni.
Sì, e fai attenzione a una trappola comune.
@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)
}Nella maggior parte dei casi, è preferibile omettere il primo evento.
In questo modo, eviterai query di ricerca duplicate non necessarie o di sovrascrivere ViewModel con il valore iniziale.
Ok, quindi se la tua logica deve poi restituire un feedback, sai, per restituire un feedback su queste modifiche allo stato, ad esempio quando l'utente preme il pulsante di invio e vuoi cancellare il testo, non puoi semplicemente impostare una stringa vuota su ViewModel di 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)
}Devi invece emettere un evento di testo non crittografato, quindi la funzione componibile che contiene l'oggetto TextFieldState chiamerà clearText su quell'oggetto.
Per aiutarti ulteriormente nella transizione, abbiamo scritto questa guida alla migrazione.
Puoi accedervi tramite questo link.
Stiamo anche lavorando ad alcune API aggiuntive per semplificare sia la migrazione sia l'osservazione delle modifiche generali di TextFieldState, quindi non perderti i prossimi aggiornamenti.
Tutto ciò che abbiamo trattato finora, TextFieldState, TextFieldBuffer, trasformazione di input e output, non è stato creato solo per l'input di testo normale.
Queste sono le basi su cui si fonda il nostro nuovo set di API per la modifica del testo formattato.
Ora passo la parola ad Haoyu, che vi spiegherà meglio.
Okay.
Grazie.
Ciao,
Ora parlerò delle nuove API che abbiamo introdotto per la modifica del testo formattato.
Quindi, prima di approfondire le nuove API, facciamo un passo indietro e vediamo come funziona lo stile nel testo statico.
La maggior parte di voi conosce già molto bene 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)È il modo standard per applicare uno stile a un testo statico.
Utilizziamo buildAnnotatedString e chiamiamo add-- call addStyle per applicare SpanStyle o ParagraphStyle su determinati intervalli specificati.
Funziona bene per il testo statico.
Quindi, quando guardiamo la modifica del testo formattato, il nostro primo pensiero è stato: possiamo semplicemente rendere modificabile AnnotatedString?
Ma poi ci siamo resi conto che non è così semplice.
AnnotatedString è una struttura di dati immutabile.
Ma nel mondo dell'editing, l'utente digita costantemente.
E con ogni tasto, ogni pressione di un tasto, dobbiamo allocare un nuovo oggetto AnnotatedString.
Inoltre, ogni AnnotatedString contiene molti AnnotatedString.Range.
Pertanto, anche questi oggetti devono essere ricreati.
Non scala molto bene e causa un overhead di allocazione significativo.
Dai un'occhiata a questo esempio.
Quando inseriamo la parola al centro, ogni stile successivo ha spostato il proprio intervallo e viene riassegnato.
Questo approccio non funziona se hai molti stili.
Per questo motivo, abbiamo introdotto API di stile dedicate all'interno di TextFieldBuffer, di cui Anastasia ha parlato in precedenza.
Sono molto simili alle API di stile in AnnotatedString.
Vediamo come funzionano.
Quindi, proprio come AnnotatedString.Builder, TextFieldBuffer ora ha addStyle per applicare SpanStyle o ParagraphStyle a un intervallo.
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>Puoi anche passare un parametro ExpandPolicy, che controlla il comportamento dello stile quando il testo viene digitato esattamente ai limiti dello stile.
Lo vedremo tra un attimo.
Per prima cosa, vediamo come funziona il monitoraggio dello stile.
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)
}Una volta aggiunto uno stile a TextFieldBuffer, il suo intervallo si aggiorna automaticamente man mano che il testo viene modificato.
In questo esempio, iniziamo applicando il grassetto ad alcune parti del testo.
Quando inserisci del testo prima, l'intervallo di stile si sposta in avanti.
Quando inserisci del testo al suo interno, l'intervallo di testo si espande.
Quando eliminiamo il testo all'interno dell'intervallo, l'intervallo di stile si riduce di conseguenza.
Se tutto il testo all'interno dell'intervallo di stile viene eliminato, lo stile viene compresso e rimosso automaticamente dal buffer.
Cosa succede quando il testo viene inserito esattamente ai limiti di un intervallo di stile?
Il testo appena inserito è coperto dallo stile o no?
Per consentirti di controllare questo aspetto, abbiamo introdotto le API ExpandPolicy.
Esaminiamole nel dettaglio.
Il primo è 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")In questa modalità, l'inserimento di testo al confine non espande gli intervalli di stile.
In questo caso, applichiamo prima un intervallo in grassetto.
Se inseriamo del testo all'inizio dell'intervallo in grassetto, l'intervallo di stile si sposta, ma non si espande.
Analogamente, se inseriamo del testo alla fine dell'intervallo in grassetto, l'intervallo di stile non si espande.
Ecco una demo che mostra come funziona in un editor.
Il prossimo è ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Come suggerisce il nome, l'inserimento di testo all'inizio dell'intervallo espande l'intervallo di stile.
Quindi, quando inseriamo il testo subito prima, l'intervallo di stile si espande per coprire il testo appena inserito.
Ecco un'altra demo che mostra come funziona.
Il terzo è ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Quindi, inserendo il testo alla fine dell'intervallo, l'intervallo di stile viene espanso.
Questa è anche l'impostazione più comune per gli editor.
Ecco una demo che mostra come funziona.
Infine, abbiamo ExpandPolicy.AtBoth, che espande lo stile quando il testo viene inserito all'inizio e alla fine dell'intervallo.
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")
}Ecco un'altra demo.
Finora abbiamo trattato tutte le API per l'applicazione degli stili.
Inoltre, abbiamo introdotto nuove API di query come getSpanStyles, getParagraphStyles per recuperare gli stili attualmente applicati in un determinato intervallo.
// 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>>Ecco due versioni delle API di query.
Su TextFieldState, le API di query di stile sono di sola lettura.
Restituiscono un elenco di oggetti AnnotatedString.Range immutabili.
Invece, in TextFieldBuffer, getSpanStyles ha restituito un elenco di oggetti TrackedRange appena introdotti, che presenterò in seguito.
Pertanto, tutte le query di stile tengono conto dell'ordine.
Restituiscono gli intervalli di stile esattamente nell'ordine in cui viene applicato lo stile.
In questo modo, proprio come con AnnotatedString, puoi applicare un altro stile sopra uno stile esistente per sovrascrivere quelli precedenti.
Ora passiamo all'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<*>): BooleanGli oggetti TrackedRange vengono restituiti dalle API addStyle o getSpanStyles in TextFieldBuffer.
Immaginalo come un handle attivo dell'intervallo di stile nel buffer.
Puoi utilizzarlo per aggiornare l'intervallo di testo, ExpandPolicy o lo stile stesso nella posizione.
Dispone inoltre di una proprietà isValid per verificare se l'intervallo di stile è ancora presente in TextFieldBuffer.
Questo perché lo stile può essere rimosso se viene compresso durante la modifica o se viene rimosso deliberatamente dal removeStyle.
Ora vediamo come queste API si uniscono in casi d'uso reali.
Il primo caso d'uso è la creazione di un editor WYSIWYG (What You See Is What You Get).
In questo esempio, abbiamo implementato la funzionalità del pulsante per il grassetto.
Se fai clic sul pulsante Grassetto, lo stile grassetto viene attivato o disattivato sul testo selezionato.
Ora diamo un'occhiata al codice.
Quindi, quando l'utente tocca il pulsante per il grassetto, chiamiamo prima getSpanStyles su una selezione per trovare tutti gli stili sovrapposti.
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())
}
}Dopodiché controlliamo se l'intera selezione è già in grassetto o meno.
In questo caso, la parola selezionata non è in grassetto.
Poiché non è in grassetto, addStyle all'intervallo selezionato.
Passiamo ExpandPolicy.AtEnd, in modo che l'utente possa continuare a digitare in grassetto subito dopo questa parola.
Ora vediamo cosa succede se l'intervallo selezionato è già in grassetto.
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)
}
}In questo caso, dobbiamo cancellare lo stile grassetto dall'intervallo selezionato.
In questo caso, la parola selezionata dall'utente si trova all'interno della frase in grassetto più lunga.
Poiché lo stile in grassetto si estende prima della selezione, dobbiamo aggiornare l'intervallo di stile, lasciando la parola selezionata non in grassetto.
Per farlo, possiamo utilizzare un'API TrackedRange.
In questo caso specifico, anziché eliminare questo stile e ricrearlo, possiamo semplicemente modificare l'intervallo dello span in grassetto in modo che termini prima della selezione all'indice 10.
Naturalmente, questa è un'implementazione molto semplificata.
Per una demo completa, puoi consultare il nostro repository di esempi di Compose.
Ora diamo un'occhiata a uno stile basato su pattern.
In questa demo, quando l'utente digita gli asterischi di Markdown intorno a una parola, l'editor la converte automaticamente in testo in grassetto durante la digitazione.
Ora vediamo come viene implementato utilizzando 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
)
}
}
}Nel nostro filtro di input di trasformazione, troviamo tutti i pattern corrispondenti sul pulsante.
Per ogni corrispondenza, estraiamo i contenuti all'interno dei doppi asterischi.
In questo esempio, si tratta della parola "volpe".
Poi, rimuoviamo gli asterischi e sostituiamo il pattern corrispondente con i relativi contenuti.
Infine, chiamiamo addStyle su bold per applicare lo stile grassetto al testo dei contenuti.
Ricorda che InputTransformation invierà lo stato finale del campo di testo.
Ciò significa che lo stile grassetto viene applicato allo stato in modo permanente.
Infine, diamo un'occhiata all'esempio di evidenziazione della ricerca.
In questo esempio, quando l'utente digita una query di ricerca, le parole corrispondenti nei campi di testo vengono evidenziate, senza modificare lo stato del campo di testo.
Ora vediamo come viene implementato in OutputTransformation.
Quando evidenzi una parola di ricerca, non devi mai modificare lo stato del testo.
OutputTransformation è il luogo perfetto per implementare questo caso d'uso.
Vediamo l'implementazione effettiva.
Innanzitutto, cerchiamo le parole evidenziate e troviamo tutte le corrispondenze.
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)
}
}
}Tieni presente che la query è uno stato modificabile.
Questo perché OutputTransformation è compatibile con gli snapshot.
Quando questi stati modificabili vengono aggiornati, OutputTransformation viene riapplicato automaticamente.
Poi, quando eseguiamo il loop di tutte le corrispondenze e chiamiamo addStyle per disegnare le evidenziazioni gialle sull'intervallo di corrispondenza.
Anche in questo caso, OutputTransformation modifica solo il testo visivo, mentre gli stati del testo originale rimangono invariati.
Ok, questi sono tutti i casi d'uso che abbiamo.
Ti ricordiamo che tutte le API mostrate in precedenza sono disponibili a partire da Compose 1.12.
Per concludere, voglio condividere un'anteprima del nuovo progetto su cui stiamo lavorando.
Si tratta di una libreria di testo di markup per Jetpack Compose, che supporta formati RTF come Markdown.
Una delle funzionalità più interessanti che supportiamo è l'analisi e il rendering incrementali.
Quindi è molto adatto per l'editing in tempo reale o lo streaming di Markdown.
Ecco un video del nostro prototipo funzionante in azione. Come puoi vedere, le aree che sbiadiscono sono le parti in fase di aggiornamento nell'interfaccia utente.
Se ti interessa o se hai un caso d'uso nella tua app, non esitare a contattarci.
Grazie a tutti.
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)
}
}
}