Nozioni di base sulle app

Creazione di esperienze di modifica del testo affidabili in Compose

Altoparlanti

  • Anastasia Soboleva

    Software engineer

  • Haoyu Zhang

    Senior Software Engineer

Informazioni

Informazioni sulla presentazione

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.

Riepilogo

  • L'overload dell'API 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.
  • Le trasformazioni di input e output ti consentono di manipolare il testo utilizzando TextFieldBuffer. Puoi intercettare le modifiche prima di eseguirne il commit o formattare il testo visualizzato senza calcolare manualmente le mappature degli offset.
  • L'hoisting di 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.
  • Compose 1.12 aggiunge le API di modifica del testo formattato a TextFieldBuffer. Puoi applicare stili con opzioni ExpandPolicy personalizzabili e gli intervalli di stile esistenti vengono aggiornati automaticamente man mano che gli utenti apportano modifiche.
  • È attualmente in fase di sviluppo una nuova libreria di testo di markup per Jetpack Compose. Supporta formati come Markdown con analisi e rendering incrementali per l'editing e lo streaming in tempo reale.

Risorse

Gemini Notebook

Argomenti

  • jetpack-compose
  • textfieldstate
  • modifica del testo
  • basictextfield
  • formatted-text
  • input-transformation

Trascrizione

Scarica la trascrizione

Introduzione alla modifica del testo in Compose

  • 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) -> Unit
  • Ciò 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.

Introduzione a TextFieldState

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

Filtro e formattazione degli input

  • 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.

Hoisting e migrazione di TextFieldState

  • 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.

Editor di testo formattato e RTF

  • 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<*>): Boolean
  • Gli 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.

Creazione di un editor WYSIWYG

  • 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.

Libreria di testo di markup imminente

  • 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.

Codice

Snippet di codice

Snippet Kotlin al minuto 00:29 in "Introduzione all'editing di testo in Compose"
TextField(state = rememberTextFieldState())
Snippet Kotlin al minuto 00:29 in "Introduction to text editing in Compose"
OutlinedTextField(state = rememberTextFieldState())
Snippet di Kotlin al minuto 00:34 in "Introduction to text editing in Compose"
BasicTextField(state = rememberTextFieldState())
Snippet di Kotlin al minuto 00:44 in "Introduzione all'editing di testo in Compose"
SecureTextField(state = rememberTextFieldState())
Snippet Kotlin al minuto 00:58 in "Introduction to editor di testo in Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Snippet di Kotlin al minuto 01:28 in "Introduction to text editing in Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
Snippet di Kotlin al minuto 04:01 in "Introducing TextFieldState"
// 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) }
Snippet di Kotlin al minuto 04:09 in "Introducing TextFieldState"
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
Snippet Kotlin al minuto 04:25 in "Introducing TextFieldState"
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
Snippet di Kotlin al minuto 04:38 in "Introducing TextFieldState"
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
Snippet Kotlin al minuto 04:55 in "Input filtering and formatting"
onValueChange { newString ->
  // full diffing new String vs old String
}
Snippet di Kotlin al minuto 05:17 in "Input filtering and formatting"
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
Snippet Kotlin al minuto 05:33 in "Input filtering and formatting"
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
Snippet di Kotlin al minuto 06:48 in "Input filtering and formatting"
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
Snippet Kotlin al minuto 06:58 in "Input filtering and formatting"
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
Snippet di Kotlin al minuto 07:51 in "Hoisting and migrating TextFieldState"
@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()) }) { }
    )
}
Snippet di Kotlin alle 08:58 in "Hoisting and migrating TextFieldState"
@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)
        }
    }
Snippet di Kotlin al minuto 09:27 in "Hoisting and migrating TextFieldState"
@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,
    )
}
Snippet di Kotlin alle 10:12 in "Hoisting and migrating TextFieldState"
@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) { ... }

}
Snippet di Kotlin al minuto 10:30 in "Hoisting and migrating TextFieldState"
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
Snippet di Kotlin al minuto 10:51 in "Hoisting and migrating TextFieldState"
data class FormUiState(val username: TextFieldState)
Snippet di Kotlin alle 11:38 in "Hoisting and migrating TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Snippet Kotlin al minuto 12:09 in "Hoisting and migrating TextFieldState"
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
Snippet di Kotlin al minuto 12:55 in "Hoisting and migrating TextFieldState"
@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)
}
Snippet di Kotlin al minuto 13:21 in "Hoisting and migrating TextFieldState"
@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)
}
Snippet di Kotlin alle 13:39 in "Hoisting and migrating TextFieldState"
@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)
}
Snippet di Kotlin al minuto 15:09 in "Formatted and rich text editing"
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)
Snippet Kotlin al minuto 16:36 in "Formatted and rich text editing"
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>
Snippet Kotlin al minuto 16:57 in "Formatted and rich text editing"
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)
}
Snippet di Kotlin al minuto 17:52 in "Formatted and rich text editing"
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")
Snippet di Kotlin al minuto 18:22 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
Snippet Kotlin al minuto 18:40 in "Formatted and rich text editing"
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
Snippet Kotlin alle 18:56 in "Formatted and rich text editing"
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")
}
Snippet di Kotlin alle 19:14 in "Formatted and rich text editing"
// 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>>
Snippet di Kotlin al minuto 20:03 in "Formatted and rich text editing"
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<*>): Boolean
Snippet di Kotlin al minuto 21:01 in "Creazione di un editor WYSIWYG"
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())
    }
}
Snippet di Kotlin al minuto 21:26 in "Building a WYSIWYG editor"
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)
    }
}
Snippet Kotlin al minuto 22:27 in "Creazione di un editor WYSIWYG"
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
            )
        }
    }
}
Snippet Kotlin al minuto 23:42 in "Creazione di un editor WYSIWYG"
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)
        }
    }
}