Stile dell'input di testo RTF

L'input RTF consente agli utenti di formattare ciò che digitano, ad esempio mettendo in grassetto una parola, aggiungendo un'intestazione, allineando un paragrafo o inserendo un @mention evidenziato. In Compose, puoi creare questa funzionalità sopra i campi di testo basati sullo stato (BasicTextField e TextField). Gli stili si trovano in TextFieldState insieme al testo, quindi sopravvivono alle modifiche, alle variazioni di selezione e alle ricomposizioni e rimangono sincronizzati mentre l'utente digita, elimina e incolla.

Questa pagina mostra come applicare, aggiornare, eseguire query, estrarre e rimuovere gli stili in un TextFieldState utilizzando TextFieldBuffer e come gestire una barra degli strumenti di formattazione da TextFieldState.textStyles.

Applica stili in linea e di paragrafo

Puoi applicare la formattazione a livello di carattere e di paragrafo all'interno di qualsiasi ambito del ricevitore TextFieldBuffer, inclusi TextFieldState.edit, InputTransformation.transformInput e OutputTransformation.transformOutput.

Controlla l'espansione dei confini con ExpandPolicy

Quando chiami addStyle, puoi passare un ExpandPolicy per definire il comportamento dell'intervallo con stile quando l'utente inserisce nuovi caratteri direttamente al suo confine start o end:

  • Inserimento all'interno di (start, end): espande sempre l'intervallo con stile.
  • Inserimento prima di start: sposta sempre gli offset start e end verso destra.
  • Inserimento direttamente in start o end: controllato dal ExpandPolicy specificato.

La tabella seguente descrive le opzioni ExpandPolicy disponibili e i relativi casi d'uso tipici:

Norme Comportamento dei confini Casi d'uso consigliati Esempio
ExpandPolicy.InsideOnly Il testo digitato in start o end rimane al di fuori dell'intervallo con stile. Token discreti come @mentions, #hashtags o pillole di sfondo in cui la digitazione accanto al tag non deve ereditare il colore di sfondo del tag. Animazione che mostra il testo digitato all'inizio o alla fine di un intervallo ExpandPolicy.InsideOnly che rimane senza stile.
ExpandPolicy.AtStart Il testo digitato in start eredita lo stile, mentre il testo digitato in end rimane senza stile. Anche il testo digitato direttamente davanti al testo con stile deve adottare la stessa formattazione. Animazione che mostra il testo digitato all'inizio di un intervallo ExpandPolicy.AtStart che eredita lo stile, mentre il testo digitato alla fine rimane senza stile.
ExpandPolicy.AtEnd Il testo digitato in end eredita lo stile, mentre il testo digitato in start rimane senza stile. Digitazione standard nell'editor di testo RTF, in cui la formattazione in grassetto o corsivo viene mantenuta se continui a digitare dopo una parola in grassetto o corsivo. Animazione che mostra il testo digitato alla fine di un intervallo ExpandPolicy.AtEnd che eredita lo stile, mentre il testo digitato all'inizio rimane senza stile.
ExpandPolicy.AtBoth Il testo digitato in start o end espande l'intervallo ed eredita lo stile. Evidenziazioni attive o blocchi formattati continui che dovrebbero espandersi da entrambi i lati. Animazione che mostra il testo digitato all'inizio o alla fine di un intervallo ExpandPolicy.AtBoth che eredita lo stile.

Applica la formattazione dei caratteri con SpanStyle

Utilizza addStyle con un SpanStyle per applicare la formattazione a livello di carattere, come il colore del testo, l'evidenziazione dello sfondo, FontWeight, FontStyle o la sottolineatura a un TextRange specifico nel buffer.

Comportamenti chiave per gli intervalli SpanStyle

  • Ordine di sovrapposizione: gli stili vengono applicati nell'ordine in cui li aggiungi al buffer. Se più intervalli SpanStyle sovrapposti impostano la stessa proprietà con valori diversi, lo stile aggiunto successivamente viene visualizzato sopra.
  • Requisito di intervallo non vuoto: devi passare un intervallo non compresso in cui start < end. La chiamata di addStyle su una selezione compressa di lunghezza 0 non ha alcun effetto e il passaggio di indici invertiti o fuori intervallo genera un IllegalArgumentException.

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

Un TextField che applica dinamicamente la formattazione in grassetto e corsivo rosso mentre l'utente digita.
Figura 1. Applicazione dinamica della formattazione in grassetto e corsivo rosso all'input utente.

Applica la formattazione dei blocchi con ParagraphStyle

Utilizza addStyle con un ParagraphStyle per applicare la formattazione strutturale a livello di blocco, ad esempio textAlign, lineHeight, textDirection o textIndent, a un intervallo di paragrafi.

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

Un TextField che attiva/disattiva l'allineamento del paragrafo tra inizio, centro e fine utilizzando ParagraphStyle.
Figura 2. Attivazione/disattivazione dell'allineamento del paragrafo tra inizio, centro e fine utilizzando ParagraphStyle.

Insidie da evitare con ParagraphStyle

Poiché ParagraphStyle si applica a interi blocchi di testo, evita di utilizzarlo nei seguenti scenari:

  • Espansione nei paragrafi adiacenti: imposta ExpandPolicy con attenzione in modo che gli intervalli ParagraphStyle adiacenti non si sovrappongano dopo le modifiche dell'utente. Ad esempio, se [0, 5) utilizza ExpandPolicy.AtEnd e [5, 10) utilizza ExpandPolicy.AtStart, l'inserimento di testo nell'indice 5 espande entrambi gli stili in una sovrapposizione e genera un IllegalArgumentException.
  • Intervalli ParagraphStyle nidificati: sebbene gli intervalli completamente nidificati (come [0, 10) e [2, 8)) siano validi in AnnotatedString, evita di nidificare gli intervalli ParagraphStyle in un campo di testo. Layout suddivide gli intervalli nidificati in paragrafi separati e le modifiche successive possono inavvertitamente trasformare gli intervalli nidificati in una sovrapposizione parziale.

Aggiorna uno stile esistente con TrackedRange

La chiamata di addStyle con TextRange e ExpandPolicy restituisce un handle TrackedRange, che può essere TrackedRange<SpanStyle> o TrackedRange<ParagraphStyle>. Un TrackedRange è un riferimento dinamico a un intervallo di stile in TextFieldBuffer che aggiorna automaticamente gli offset dei caratteri start e end quando il testo viene inserito, eliminato o sostituito intorno.

Mutabilità in loco

Non è necessario chiamare removeStyle e addStyle per aggiornare uno stile esistente o modificarne i confini. All'interno di un ambito TextFieldBuffer, TrackedRange fornisce proprietà di estensione che aggiornano l'intervallo in loco, preservando l'ordine di stratificazione originale rispetto ad altri stili:

  • TrackedRange<SpanStyle>.spanStyle: legge o sostituisce il payload SpanStyle in posizione su un TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: legge o sostituisce il payload ParagraphStyle in posizione su un TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: legge o aggiorna i limiti TextRange attuali in loco. L'impostazione di un intervallo compresso, invertito o fuori dai limiti genera un errore IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: legge o aggiorna ExpandPolicy in loco.
  • TrackedRange<*>.isValid: restituisce true se l'intervallo di stile è ancora attivo nel buffer o false se le modifiche hanno ridotto la sua lunghezza a 0 o se è stato rimosso.

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

Limitazione dell'ambito e regole del ciclo di vita

Tieni presente le seguenti regole relative all'ambito e al ciclo di vita quando utilizzi le istanze TrackedRange:

  • Limitato a TextFieldBuffer: le proprietà di estensione TrackedRange esistono solo nell'ambito TextFieldBuffer in cui l'handle è stato creato o sottoposto a query.
  • Eliminazione automatica in caso di compressione: se le modifiche dell'utente eliminano tutti i caratteri coperti da un TrackedRange, la sua lunghezza si riduce a 0, isValid restituisce false e lo stile viene rimosso dal buffer.

Esegui query e rimuovi gli stili

Compose offre due modi per ispezionare gli stili attivi, a seconda che tu stia modificando il buffer o leggendo lo stato della UI:

  • All'interno di un blocco TextFieldBuffer: chiama getSpanStyles e getParagraphStyles direttamente sul ricevitore TextFieldBuffer per ottenere handle TrackedRange modificabili.
  • Al di fuori di un blocco di modifica: leggi TextFieldState.textStyles, che restituisce elenchi AnnotatedString.Range immutabili che riflettono lo stato di commit.

Esegui query sugli stili di intersezione in TextFieldBuffer

TextFieldBuffer.getSpanStyles e TextFieldBuffer.getParagraphStyles restituiscono tutte le istanze TrackedRange che si intersecano con l'elemento TextRange specificato, ordinate in base al momento in cui sono state aggiunte al buffer.

Ad esempio, puoi eseguire una query su getSpanStyles(selection) all'interno di state.edit per esaminare o aggiornare gli stili che coprono la selezione corrente dell'utente:

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

Gli intervalli di stile utilizzano un intervallo matematico semiaperto [start, end). La restituzione di uno stile di contatto dipende dal fatto che tu esegua una query su una selezione o su un singolo punto del cursore:

  • Per una selezione non vuota [start, end):
    • Restituiti: qualsiasi stile che si sovrappone, contiene o si trova all'interno dell'intervallo di selezione. Ad esempio, la query [5, 15) corrisponde a [0, 20), [8, 12) o [0, 10).
    • Non restituito: stili che toccano solo i bordi esterni senza sovrapporsi ai caratteri. Ad esempio, la query [5, 15) esclude [0, 5) e [15, 25).
  • Per un cursore compresso [index, index]:
    • Restituito: qualsiasi stile contenente il cursore o che inizia nella posizione del cursore. Ad esempio, un cursore in 10 corrisponde a [0, 20) e [10, 20).
    • Non restituito (regola di fine contatto): qualsiasi stile che termina nella posizione del cursore. Ad esempio, un cursore in 10 esclude [0, 10).

Rimuovi o attiva/disattiva gli stili con removeStyle

Passa un handle TrackedRange a removeStyle per eliminare l'intervallo di stile specifico da TextFieldBuffer.

removeStyle restituisce true se TrackedRange è stato trovato e rimosso, oppure false se era già stato rimosso o apparteneva a un buffer diverso. Rimuove solo l'handle TrackedRange scelto come target, lasciando intatti gli altri stili sovrapposti.

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

Un TextField che attiva e disattiva la formattazione in grassetto per le parole selezionate.
Figura 3. Attivazione/disattivazione del grassetto per il testo.

Estrai o serializza RTF

Per visualizzare l'input formattato in un composable Text di sola lettura o serializzarlo in un servizio di backend, leggi TextFieldState.textStyles al di fuori di un blocco di modifica.

state.textStyles.getSpanStyles e state.textStyles.getParagraphStyles restituiscono List<AnnotatedString.Range<SpanStyle>> e List<AnnotatedString.Range<ParagraphStyle>>, che puoi passare direttamente al costruttore AnnotatedString:

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

Esempio completo in Jetchat

Per un'implementazione completa che combina lo stile dei caratteri in linea (grassetto, corsivo, sottolineato, barrato e colore), consulta l'esempio Jetchat su GitHub:

  • RichTextState.kt: incapsula le query di stile TextFieldState, l'attivazione/disattivazione degli intervalli, il ritaglio o la suddivisione dei limiti di selezione e altro ancora.
  • UserInput.kt: collega RichTextState alla barra degli strumenti di formattazione del testo RTF e al campo di immissione del messaggio.
L'app di esempio Jetchat che mostra la formattazione RTF nella barra di inserimento dei messaggi.
Figura 4. Modifica di RTF in Jetchat.