Saisie de texte enrichi stylisé

La saisie de texte enrichi permet aux utilisateurs de mettre en forme ce qu'ils saisissent, par exemple en mettant un mot en gras, en ajoutant un titre, en alignant un paragraphe ou en insérant un @mention mis en surbrillance. Dans Compose, vous créez cette fonctionnalité par-dessus les champs de texte basés sur l'état (BasicTextField et TextField). Les styles se trouvent dans TextFieldState, à côté du texte. Ils survivent donc aux modifications, aux changements de sélection et aux recompositions, et restent synchronisés lorsque l'utilisateur saisit, supprime et colle du texte.

Cette page explique comment appliquer, mettre à jour, interroger, extraire et supprimer des styles dans un TextFieldState à l'aide de TextFieldBuffer, et comment piloter une barre d'outils de mise en forme à partir de TextFieldState.textStyles.

Appliquer des styles intégrés et de paragraphe

Vous pouvez appliquer une mise en forme au niveau des caractères et des paragraphes dans n'importe quel champ d'application du récepteur TextFieldBuffer, y compris TextFieldState.edit, InputTransformation.transformInput et OutputTransformation.transformOutput.

Contrôler l'expansion des limites avec ExpandPolicy

Lorsque vous appelez addStyle, vous pouvez transmettre un ExpandPolicy pour définir le comportement de la plage stylisée lorsque l'utilisateur insère de nouveaux caractères directement à sa limite start ou end :

  • Insertion dans (start, end) : étend toujours la plage stylisée.
  • Insertion avant start : décale toujours les décalages start et end vers la droite.
  • Insertion directement au niveau de start ou end : contrôlée par le ExpandPolicy que vous spécifiez.

Le tableau suivant décrit les options ExpandPolicy disponibles et leurs cas d'utilisation types :

Règlement Comportement des limites Cas d'utilisation recommandés Exemple
ExpandPolicy.InsideOnly Le texte saisi au niveau de start ou end reste en dehors de la plage stylisée. Jetons discrets tels que @mentions, #hashtags ou les pastilles d'arrière-plan, où la saisie à côté du tag ne doit pas hériter de la couleur d'arrière-plan du tag. Animation montrant que le texte saisi au début ou à la fin d'une plage ExpandPolicy.InsideOnly n'est pas stylisé.
ExpandPolicy.AtStart Le texte saisi à l'emplacement start hérite du style, tandis que le texte saisi à l'emplacement end reste sans style. Le texte saisi directement devant le texte stylisé doit également adopter la même mise en forme. Animation montrant que le texte saisi au début d'une plage ExpandPolicy.AtStart hérite du style, tandis que le texte saisi à la fin reste sans style.
ExpandPolicy.AtEnd Le texte saisi à l'emplacement end hérite du style, tandis que le texte saisi à l'emplacement start reste sans style. Saisie standard dans l'éditeur de texte enrichi, où la saisie après un mot en gras ou en italique conserve cette mise en forme. Animation montrant du texte saisi à la fin d'une plage ExpandPolicy.AtEnd héritant du style, tandis que le texte saisi au début reste sans style.
ExpandPolicy.AtBoth Le texte saisi au niveau de start ou de end étend la plage et hérite du style. Mises en surbrillance actives ou blocs formatés continus qui doivent s'étendre à partir de l'un des bords. Animation montrant du texte saisi au début ou à la fin d'une plage ExpandPolicy.AtBoth héritant du style.

Appliquer la mise en forme des caractères avec SpanStyle

Utilisez addStyle avec un SpanStyle pour appliquer une mise en forme au niveau des caractères, comme la couleur du texte, la mise en surbrillance de l'arrière-plan, FontWeight, FontStyle ou le soulignement d'un TextRange spécifique dans le tampon.

Comportements clés pour les plages SpanStyle

  • Ordre de superposition : les styles s'appliquent dans l'ordre dans lequel vous les ajoutez au tampon. Si plusieurs plages SpanStyle qui se chevauchent définissent la même propriété avec des valeurs différentes, le style ajouté ultérieurement s'affiche au-dessus.
  • Exigence de plage non vide : vous devez transmettre une plage non réduite où start < end. L'appel de addStyle sur une sélection réduite de longueur 0 n'a aucun effet, et la transmission d'index inversés ou hors limites génère une exception 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 appliquant de manière dynamique une mise en forme en gras et en italique rouge à mesure que l'utilisateur saisit du texte.
Figure 1. Appliquer dynamiquement une mise en forme en gras et en italique rouge à l'entrée utilisateur.

Appliquer une mise en forme de bloc avec ParagraphStyle

Utilisez addStyle avec un ParagraphStyle pour appliquer une mise en forme structurelle au niveau du bloc, telle que textAlign, lineHeight, textDirection ou textIndent, à une plage de paragraphes.

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 qui bascule l'alignement des paragraphes entre le début, le centre et la fin à l'aide de ParagraphStyle.
Figure 2. Basculez l'alignement des paragraphes entre le début, le centre et la fin à l'aide de ParagraphStyle.

Pièges à éviter avec ParagraphStyle

Étant donné que ParagraphStyle s'applique à des blocs de texte entiers, évitez de l'utiliser dans les scénarios suivants :

  • Expansion dans les paragraphes adjacents : définissez ExpandPolicy avec soin afin que les plages ParagraphStyle adjacentes ne se chevauchent pas après les modifications de l'utilisateur. Par exemple, si [0, 5) utilise ExpandPolicy.AtEnd et [5, 10) utilise ExpandPolicy.AtStart, l'insertion de texte à l'index 5 étend les deux styles en un chevauchement et génère une IllegalArgumentException.
  • Imbrication de plages ParagraphStyle : bien que les plages complètement imbriquées (telles que [0, 10) et [2, 8)) soient valides dans AnnotatedString, évitez d'imbriquer des plages ParagraphStyle dans un champ de texte. La mise en page divise les plages imbriquées en paragraphes distincts, et les modifications ultérieures peuvent par inadvertance transformer les plages imbriquées en chevauchement partiel.

Mettre à jour un style en place avec TrackedRange

L'appel de addStyle avec un TextRange et un ExpandPolicy renvoie un handle TrackedRange, qui peut être TrackedRange<SpanStyle> ou TrackedRange<ParagraphStyle>. Un TrackedRange est une référence dynamique à un intervalle de style dans le TextFieldBuffer qui met automatiquement à jour ses décalages de caractères start et end lorsque du texte est inséré, supprimé ou remplacé autour de lui.

Mutabilité sur place

Vous n'avez pas besoin d'appeler removeStyle et addStyle pour mettre à jour un style existant ou ajuster ses limites. Dans un champ d'application TextFieldBuffer, TrackedRange fournit des propriétés d'extension qui mettent à jour l'intervalle en place tout en préservant son ordre de superposition d'origine par rapport aux autres styles :

  • TrackedRange<SpanStyle>.spanStyle : lit ou remplace la charge utile SpanStyle en place sur un TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle : lit ou remplace la charge utile ParagraphStyle en place sur un TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange : lit ou met à jour les limites TextRange actuelles sur place. La définition d'une plage réduite, inversée ou hors limites génère une erreur IllegalArgumentException.
  • TrackedRange<*>.expandPolicy : lit ou met à jour le ExpandPolicy sur place.
  • TrackedRange<*>.isValid : renvoie true si l'intervalle de style est toujours actif dans le tampon, ou false si des modifications ont réduit sa longueur à 0 ou s'il a été supprimé.

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

Règles de confinement et de cycle de vie du champ d'application

Tenez compte des règles de champ d'application et de cycle de vie suivantes lorsque vous utilisez des instances TrackedRange :

  • Limité à TextFieldBuffer : les propriétés d'extension TrackedRange n'existent que dans le champ d'application TextFieldBuffer où le handle a été créé ou interrogé.
  • Élagage automatique lors de la réduction : si les modifications de l'utilisateur suppriment tous les caractères couverts par un TrackedRange, sa longueur est réduite à 0, isValid renvoie false et le style est supprimé du tampon.

Interroger et supprimer des styles

Compose propose deux façons d'inspecter les styles actifs, selon que vous modifiez le tampon ou que vous lisez l'état de votre UI :

  • Dans un bloc TextFieldBuffer : appelez getSpanStyles et getParagraphStyles directement sur le récepteur TextFieldBuffer pour obtenir des poignées TrackedRange modifiables.
  • En dehors d'un bloc de modification : lisez TextFieldState.textStyles, qui renvoie des listes AnnotatedString.Range immuables reflétant l'état validé.

Interroger les styles d'intersection dans TextFieldBuffer

TextFieldBuffer.getSpanStyles et TextFieldBuffer.getParagraphStyles renvoient toutes les instances TrackedRange qui croisent l'élément TextRange donné, classées par ordre d'ajout au tampon.

Par exemple, vous pouvez interroger getSpanStyles(selection) dans state.edit pour inspecter ou mettre à jour les styles couvrant la sélection actuelle de l'utilisateur :

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

Les intervalles de style utilisent une plage mathématique semi-ouverte [start, end). Le renvoi d'un style adjacent dépend de l'interrogation d'une sélection ou d'un seul point de curseur :

  • Pour une sélection non vide [start, end) :
    • Renvoyé : tout style qui chevauche, contient ou se trouve dans la plage de sélection. Par exemple, la requête [5, 15) correspond à [0, 20), [8, 12) ou [0, 10).
    • Non renvoyés : styles qui ne touchent que les bords extérieurs sans chevaucher les caractères. Par exemple, la requête [5, 15) exclut [0, 5) et [15, 25).
  • Pour un curseur réduit [index, index] :
    • Résultat : tout style contenant le curseur ou commençant à la position du curseur. Par exemple, un curseur à 10 correspond à [0, 20) et [10, 20).
    • Non renvoyé (règle de fin de contact) : tout style se terminant à la position du curseur. Par exemple, un curseur à 10 exclut [0, 10).

Supprimer ou activer/désactiver des styles avec removeStyle

Transmettez un handle TrackedRange à removeStyle pour supprimer cet intervalle de style spécifique de TextFieldBuffer.

removeStyle renvoie true si l'élément TrackedRange a été trouvé et supprimé, ou false s'il a déjà été supprimé ou s'il appartenait à un autre tampon. Il ne supprime que le handle TrackedRange ciblé, laissant intacts les autres styles qui se chevauchent.

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 activant et désactivant la mise en forme en gras pour les mots sélectionnés.
Figure 3. Basculer entre la mise en gras et la suppression de la mise en gras du texte.

Extraire ou sérialiser du texte enrichi

Pour afficher une entrée mise en forme dans un composable Text en lecture seule ou la sérialiser dans un service de backend, lisez TextFieldState.textStyles en dehors d'un bloc de modification.

state.textStyles.getSpanStyles et state.textStyles.getParagraphStyles renvoient List<AnnotatedString.Range<SpanStyle>> et List<AnnotatedString.Range<ParagraphStyle>>, que vous pouvez transmettre directement au constructeur AnnotatedString :

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

Exemple complet dans Jetchat

Pour voir une implémentation complète qui combine la mise en forme des caractères intégrés (gras, italique, souligné, barré et couleur), consultez l'exemple Jetchat sur GitHub :

  • RichTextState.kt : encapsule les requêtes de style TextFieldState, l'activation/la désactivation des segments, le rognage ou la division des limites de sélection, etc.
  • UserInput.kt : connecte RichTextState à la barre d'outils de mise en forme du texte enrichi et au champ de saisie du message.
Exemple d'application Jetchat illustrant la mise en forme de texte enrichi dans la barre de saisie de message.
Figure 4. Édition de texte enrichi dans Jetchat.