RTF-Eingabe formatieren

Mit der Rich-Text-Eingabe können Nutzer den eingegebenen Text formatieren, z. B. ein Wort fett formatieren, eine Überschrift hinzufügen, einen Absatz ausrichten oder einen hervorgehobenen @mention einfügen. In Compose erstellen Sie diese auf zustandsbasierten Textfeldern (BasicTextField und TextField). Stile befinden sich in TextFieldState neben dem Text, sodass sie Änderungen, Auswahländerungen und Neuzusammensetzungen überstehen und synchron bleiben, wenn der Nutzer Text eingibt, löscht und einfügt.

Auf dieser Seite wird beschrieben, wie Sie mit TextFieldBuffer Stile in einem TextFieldState anwenden, aktualisieren, abfragen, extrahieren und entfernen und wie Sie eine Formatierungssymbolleiste aus TextFieldState.textStyles steuern.

Inline- und Absatzstile anwenden

Sie können Formatierungen auf Zeichen- und Absatzebene innerhalb eines beliebigen TextFieldBuffer-Empfängerbereichs anwenden, einschließlich TextFieldState.edit, InputTransformation.transformInput und OutputTransformation.transformOutput.

Grenzenerweiterung mit ExpandPolicy steuern

Wenn Sie addStyle aufrufen, können Sie ein ExpandPolicy übergeben, um festzulegen, wie sich der formatierte Bereich verhält, wenn der Nutzer neue Zeichen direkt an der Grenze start oder end einfügt:

  • Einfügen innerhalb von (start, end): Der formatierte Bereich wird immer erweitert.
  • Einfügen vor start: Verschiebt sowohl den start- als auch den end-Offset immer nach rechts.
  • Direktes Einfügen bei start oder end: Wird durch die von Ihnen angegebene ExpandPolicy gesteuert.

In der folgenden Tabelle werden die verfügbaren ExpandPolicy-Optionen und ihre typischen Anwendungsfälle beschrieben:

Richtlinie Grenzverhalten Empfohlene Anwendungsfälle Beispiel
ExpandPolicy.InsideOnly Text, der bei start oder end eingegeben wird, bleibt außerhalb des formatierten Bereichs. Diskrete Tokens wie @mentions, #hashtags oder Hintergrund-Pills, bei denen die Eingabe neben dem Tag nicht die Hintergrundfarbe des Tags übernehmen soll. Animation, die zeigt, dass Text, der am Anfang oder Ende eines „ExpandPolicy.InsideOnly“-Bereichs eingegeben wird, nicht formatiert wird.
ExpandPolicy.AtStart Text, der bei start eingegeben wird, übernimmt die Formatierung, während Text, der bei end eingegeben wird, unformatiert bleibt. Text, der direkt vor dem formatierten Text eingegeben wird, sollte ebenfalls dieselbe Formatierung übernehmen. Animation, die zeigt, wie Text, der am Anfang eines ExpandPolicy.AtStart-Bereichs eingegeben wird, den Stil übernimmt, während Text, der am Ende eingegeben wird, unformatiert bleibt.
ExpandPolicy.AtEnd Text, der bei end eingegeben wird, übernimmt den Stil, und Text, der bei start eingegeben wird, bleibt unformatiert. Standardeingabe im Rich-Text-Editor, bei der die Formatierung fortgesetzt wird, wenn Sie nach einem fett oder kursiv formatierten Wort weitertippen. Animation, die zeigt, wie Text, der am Ende eines ExpandPolicy.AtEnd-Bereichs eingegeben wird, den Stil übernimmt, während Text, der am Anfang eingegeben wird, unformatiert bleibt.
ExpandPolicy.AtBoth Text, der entweder bei start oder end eingegeben wird, erweitert den Bereich und übernimmt den Stil. Aktive Hervorhebungen oder kontinuierlich formatierte Blöcke, die von beiden Seiten aus wachsen sollen. Animation, die zeigt, wie Text am Anfang oder Ende eines „ExpandPolicy.AtBoth“-Bereichs eingegeben wird und den Stil übernimmt.

Zeichenformatierung mit SpanStyle anwenden

Verwenden Sie addStyle mit einem SpanStyle, um eine Formatierung auf Zeichenebene wie Textfarbe, Hintergrundhervorhebung, FontWeight, FontStyle oder Unterstreichung auf einen bestimmten TextRange im Zwischenspeicher anzuwenden.

Wichtige Verhaltensweisen für SpanStyle-Bereiche

  • Reihenfolge der Ebenen: Stile werden in der Reihenfolge angewendet, in der Sie sie dem Zwischenspeicher hinzufügen. Wenn mehrere sich überschneidende SpanStyle-Bereiche dieselbe Property mit unterschiedlichen Werten festlegen, wird der später hinzugefügte Stil darüber gerendert.
  • Anforderung für nicht leeren Bereich: Sie müssen einen nicht minimierten Bereich übergeben, in dem start < end ist. Der Aufruf von addStyle für eine minimierte Auswahl der Länge 0 hat keine Auswirkungen. Wenn umgekehrte oder außerhalb des zulässigen Bereichs liegende Indexe übergeben werden, wird ein IllegalArgumentException ausgelöst.

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

Ein TextField, das während der Eingabe dynamisch eine fette und kursive rote Formatierung anwendet.
Abbildung 1. Die Benutzereingabe wird dynamisch fett, kursiv und rot formatiert.

Blockformatierung mit ParagraphStyle anwenden

Verwenden Sie addStyle mit einem ParagraphStyle, um eine strukturelle Formatierung auf Blockebene wie textAlign, lineHeight, textDirection oder textIndent auf einen Absatzbereich anzuwenden.

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

Ein TextField, mit dem die Absatzausrichtung zwischen Anfang, Mitte und Ende mithilfe von ParagraphStyle umgeschaltet wird.
Abbildung 2: Absatzausrichtung zwischen Anfang, Mitte und Ende mit ParagraphStyle umschalten.

Zu vermeidende Fehler bei ParagraphStyle

Da ParagraphStyle auf ganze Textblöcke angewendet wird, sollten Sie es in den folgenden Szenarien nicht verwenden:

  • Erweiterung auf benachbarte Absätze: Legen Sie ExpandPolicy sorgfältig fest, damit sich benachbarte ParagraphStyle-Bereiche nach Nutzeränderungen nicht überschneiden. Wenn beispielsweise [0, 5) ExpandPolicy.AtEnd und [5, 10) ExpandPolicy.AtStart verwendet, wird durch das Einfügen von Text bei Index 5 eine Überschneidung der beiden Stile verursacht und ein IllegalArgumentException ausgegeben.
  • ParagraphStyle-Bereiche verschachteln: Vollständig verschachtelte Bereiche (z. B. [0, 10) und [2, 8)) sind in AnnotatedString zwar gültig, Sie sollten jedoch das Verschachteln von ParagraphStyle-Bereichen in einem Textfeld vermeiden. Das Layout teilt verschachtelte Bereiche in separate Absätze auf. Durch nachfolgende Änderungen können verschachtelte Bereiche versehentlich zu einer teilweisen Überschneidung werden.

Stil mit TrackedRange aktualisieren

Wenn Sie addStyle mit einem TextRange und einem ExpandPolicy aufrufen, wird ein TrackedRange-Handle zurückgegeben, das TrackedRange<SpanStyle> oder TrackedRange<ParagraphStyle> sein kann. Ein TrackedRange ist ein dynamischer Verweis auf ein Stilintervall im TextFieldBuffer, dessen Zeichenversätze start und end automatisch aktualisiert werden, wenn Text um ihn herum eingefügt, gelöscht oder ersetzt wird.

Direkte Veränderlichkeit

Sie müssen removeStyle und addStyle nicht aufrufen, um einen vorhandenen Stil zu aktualisieren oder seine Grenzen anzupassen. Innerhalb eines TextFieldBuffer-Bereichs bietet TrackedRange Erweiterungseigenschaften, mit denen das Intervall direkt aktualisiert wird, während die ursprüngliche Ebenenreihenfolge im Verhältnis zu anderen Stilen beibehalten wird:

  • TrackedRange<SpanStyle>.spanStyle: Liest oder ersetzt die SpanStyle-Nutzlast an Ort und Stelle in einem TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: Liest oder ersetzt die ParagraphStyle-Nutzlast an Ort und Stelle in einem TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: Liest oder aktualisiert die aktuellen TextRange-Grenzen direkt. Wenn Sie einen minimierten, umgekehrten oder außerhalb des zulässigen Bereichs liegenden Bereich festlegen, wird ein IllegalArgumentException-Fehler ausgegeben.
  • TrackedRange<*>.expandPolicy: Liest oder aktualisiert die ExpandPolicy direkt.
  • TrackedRange<*>.isValid: Gibt true zurück, wenn das Stilintervall im Zwischenspeicher noch aktiv ist, oder false, wenn die Länge durch Änderungen auf 0 reduziert wurde oder wenn es entfernt wurde.

// 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)
}

Bereichseinschränkung und Lebenszyklusregeln

Beachten Sie bei der Arbeit mit TrackedRange-Instanzen die folgenden Bereichs- und Lebenszyklusregeln:

  • Auf TextFieldBuffer beschränkt: TrackedRange-Erweiterungseigenschaften existieren nur innerhalb des TextFieldBuffer-Bereichs, in dem das Handle erstellt oder abgefragt wurde.
  • Automatisches Kürzen beim Minimieren: Wenn durch Nutzeränderungen alle Zeichen gelöscht werden, die von einem TrackedRange abgedeckt werden, wird die Länge auf 0 reduziert, isValid gibt false zurück und der Stil wird aus dem Zwischenspeicher entfernt.

Stile abfragen und entfernen

Compose bietet zwei Möglichkeiten, aktive Stile zu prüfen, je nachdem, ob Sie den Zwischenspeicher ändern oder den Status für Ihre UI lesen:

  • In einem TextFieldBuffer-Block: Rufe getSpanStyles und getParagraphStyles direkt auf dem TextFieldBuffer-Empfänger auf, um änderbare TrackedRange-Handles zu erhalten.
  • Außerhalb eines Bearbeitungsblocks: Lesen Sie TextFieldState.textStyles, wodurch unveränderliche AnnotatedString.Range-Listen zurückgegeben werden, die den übernommenen Status widerspiegeln.

Sich überschneidende Stile in TextFieldBuffer abfragen

TextFieldBuffer.getSpanStyles und TextFieldBuffer.getParagraphStyles geben alle TrackedRange-Instanzen zurück, die sich mit dem angegebenen TextRange überschneiden, sortiert nach dem Zeitpunkt, zu dem sie dem Puffer hinzugefügt wurden.

Sie können beispielsweise getSpanStyles(selection) in state.edit abfragen, um Stile zu prüfen oder zu aktualisieren, die die aktuelle Auswahl des Nutzers abdecken:

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
    }
    // ...
}

Stilintervalle verwenden einen halboffenen mathematischen Bereich [start, end). Ob ein Berührungsstil zurückgegeben wird, hängt davon ab, ob Sie eine Auswahl oder einen einzelnen Cursorpunkt abfragen:

  • Für eine nicht leere Auswahl [start, end):
    • Zurückgegeben: Alle Stile, die sich mit dem Auswahlbereich überschneiden, ihn enthalten oder sich darin befinden. Beispielsweise stimmt die Abfrage [5, 15) mit [0, 20), [8, 12) oder [0, 10) überein.
    • Nicht zurückgegeben: Stile, die nur die äußeren Begrenzungskanten berühren, ohne Zeichen zu überlappen. Beispiel: Die Abfrage [5, 15) schließt [0, 5) und [15, 25) aus.
  • Für einen minimierten Cursor [index, index]:
    • Zurückgegeben: Alle Stile, die den Cursor enthalten oder an der Cursorposition beginnen. Beispiel: Ein Cursor bei 10 entspricht [0, 20) und [10, 20).
    • Nicht zurückgegeben (Regel für Berührung des Endes): Alle Stile, die an der Cursorposition enden. Beispiel: Ein Cursor bei 10 schließt [0, 10) aus.

Stile mit removeStyle entfernen oder umschalten

Übergeben Sie ein TrackedRange-Handle an removeStyle, um das entsprechende Stilintervall aus dem TextFieldBuffer zu löschen.

removeStyle gibt true zurück, wenn der TrackedRange gefunden und entfernt wurde, oder false, wenn er bereits entfernt wurde oder zu einem anderen Puffer gehörte. Es wird nur der Ziel-TrackedRange-Handle entfernt, andere überlappende Stile bleiben intakt.

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

Ein TextField, mit dem die Fettformatierung für ausgewählte Wörter aktiviert und deaktiviert wird.
Abbildung 3: Zwischen fettem und normalem Text wechseln

RTF extrahieren oder serialisieren

Wenn Sie formatierte Eingaben in einer schreibgeschützten Text komponierbaren Funktion anzeigen oder in einen Backend-Dienst serialisieren möchten, lesen Sie TextFieldState.textStyles außerhalb eines Bearbeitungsblocks.

state.textStyles.getSpanStyles und state.textStyles.getParagraphStyles geben List<AnnotatedString.Range<SpanStyle>> und List<AnnotatedString.Range<ParagraphStyle>> zurück, die Sie direkt an den AnnotatedString-Konstruktor übergeben können:

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

Vollständiges Beispiel in Jetchat

Eine vollständige Implementierung, die Inline-Zeichenstile (fett, kursiv, unterstrichen, durchgestrichen und farbig) kombiniert, finden Sie im Jetchat-Beispiel auf GitHub:

  • RichTextState.kt: Kapselt TextFieldState-Stilabfragen, das Ein- und Ausschalten von Spans, das Kürzen oder Aufteilen über Auswahlgrenzen hinweg und mehr.
  • UserInput.kt: Verbindet RichTextState mit der Symbolleiste für die Rich-Text-Formatierung und dem Nachrichteneingabefeld.
Die Beispiel-App Jetchat, die die Rich-Text-Formatierung in der Nachrichteneingabeleiste demonstriert.
Abbildung 4. Rich-Text-Formatierung in Jetchat