Principes de base des applications

Créer des expériences d'édition de texte fiables dans Compose

Haut-parleurs

  • Anastasia Soboleva

    Ingénieur logiciel

  • Haoyu Zhang

    Ingénieur logiciel senior

À propos

À propos de l'intervention

Le parcours vers une modification de texte fiable dans Compose a abouti à la réécriture complète de BasicTextField. Ce passage à une architecture basée sur un tampon a permis d'éliminer définitivement les conditions de concurrence de l'IME. Cette session explique pourquoi l'ancienne synchronisation des entrées a dû être abandonnée et comment TextFieldState synchronise les mises à jour du clavier. Ensuite, nous nous pencherons sur la modification formatée pour explorer les mécanismes de TrackedRange, les coûts de performance des blocs de modification et les pièges réels derrière les limites de style, avant de jeter un coup d'œil au rendu Markdown.

Résumé

  • La surcharge de l'API TextFieldState remplace les surcharges basées sur le rappel dans Compose. Il se met à jour de manière synchrone avec la saisie de l'utilisateur, ce qui élimine les problèmes de timing avec les claviers virtuels et les recompositions inutiles.
  • Les transformations d'entrée et de sortie vous permettent de manipuler du texte à l'aide de TextFieldBuffer. Vous pouvez intercepter les modifications avant de les valider ou mettre en forme le texte affiché sans calculer manuellement les mappages de décalage.
  • La remontée de TextFieldState dépend des besoins de votre architecture. Vous pouvez le conserver localement dans un composable ou le stocker directement dans un ViewModel pour accéder à la logique métier.
  • Compose 1.12 ajoute des API de modification de texte formaté à TextFieldBuffer. Vous pouvez appliquer des styles avec des options ExpandPolicy personnalisables, et les plages de styles existantes sont automatiquement mises à jour lorsque les utilisateurs modifient le texte.
  • Une nouvelle bibliothèque de texte de balisage pour Jetpack Compose est en cours de développement. Il accepte des formats tels que Markdown avec analyse et rendu incrémentiels pour l'édition et le streaming en direct.

Ressources

Gemini Notebook

Thèmes

  • jetpack-compose
  • textfieldstate
  • text-editing
  • basictextfield
  • formatted-text
  • input-transformation

Transcription

Présentation de la modification de texte dans Compose

  • Hi, everyone!

  • Merci d'avoir assisté à notre présentation.

  • Je m'appelle Anastasia, et je suis accompagnée de mon collègue Haoyu.

  • Aujourd'hui, nous allons vous montrer comment créer une expérience d'édition de texte fiable.

  • Que vous créiez un écran de connexion simple, une barre de recherche instantanée ou une application de chat complexe, les champs de texte sont au cœur de l'interaction des utilisateurs avec vos applications Android.

  • La plupart d'entre vous utilisent donc probablement déjà des champs de texte Material ou des champs de texte entourés.

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • Il existe également un BasicTextField si vous créez votre propre système de conception ou si vous avez besoin d'une zone de modification simple.

    BasicTextField(state = rememberTextFieldState())
  • Il existe également SecureTextField, qui est spécialement conçu pour saisir des mots de passe.

    SecureTextField(state = rememberTextFieldState())
  • BasicTextField est un composant sans style qui alimente tous les autres champs de texte dans Compose.

  • Vous avez peut-être remarqué que, ces dernières années, nous avons deux formes d'API en parallèle : l'une prenant la valeur et le rappel de changement de valeur, et l'autre prenant l'objet TextFieldState.

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • Vous vous demandez peut-être laquelle nous recommandons.

  • Nous vous encourageons vivement à utiliser la surcharge TextFieldState.

  • Dans cette présentation, nous allons voir pourquoi il s'agit d'une solution avantageuse en termes d'API, de fonctionnalités et de performances.

  • Elle est stable depuis Compose 1.7.

  • En ce qui concerne les surcharges de rappel, nous les abandonnons progressivement dans Compose 1.13 pour BasicTextField et les wrappers Material.

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • Cela signifie que nous n'ajouterons pas de nouvelles fonctionnalités et que nous ne corrigerons pas de bugs, sauf s'il s'agit de problèmes critiques de sécurité ou d'accessibilité.

  • La surcharge de la valeur sans état lors d'un changement de valeur fonctionnait très bien pour les entrées synchrones.

  • Mais dès que vous l'associez à des flux asynchrones, par exemple StateFlow, cela crée des problèmes de timing avec l'IME ou, en termes techniques, avec le clavier virtuel.

  • Cela entraînait des caractères manquants, des caractères en double et des sauts de curseur, comme vous pouvez le voir sur cet enregistrement.

Présentation de TextFieldState

  • Pour résoudre ce problème de manière fondamentale, nous avons introduit TextFieldState.

  • Il se met à jour de manière synchrone lorsque l'utilisateur tape, sans attendre ces allers-retours asynchrones, ce qui est exactement ce dont l'IME avait besoin.

  • TextFieldState, en plus du texte en lecture seule, possède également d'autres propriétés qui créent une sorte de session d'édition active.

  • Il s'agit de la sélection, de l'état d'annulation/de rétablissement, ainsi que des styles de span et de paragraphe pour la modification de texte mis en forme. Nous y reviendrons plus tard dans cette présentation.

  • Les propriétés internes de TextFieldState sont sauvegardées par le système d'instantanés Compose.

  • Cela signifie que lorsque vous lisez ce texte ou cette sélection à partir de cet objet TextFieldState quelque part dans Compose, le champ d'application spécifique à partir duquel vous le lisez est abonné aux mises à jour.

  • Il permet à BasicTextField de ne lire l'état que pendant les phases de mise en page ou de dessin, et d'éliminer la recomposition inutile lorsque l'utilisateur saisit du texte.

  • Combinée à d'autres modifications, elle a permis d'améliorer considérablement les performances de la surcharge TextFieldState.

  • Maintenant, si le texte de TextFieldState est une propriété en lecture seule, comment modifier de manière programmatique votre champ de saisie ?

  • C'est là que TextFieldBuffer s'avère utile.

  • Il s'agit d'un objet modifiable qui vous fournit des API pour effectuer des modifications.

  • Ainsi, lorsque vous souhaitez effectuer une modification de manière programmatique, vous appelez la fonction de modification de TextFieldState.

  • Dans ce lambda, vous obtenez un TextFieldBuffer.

  • TextFieldBuffer expose plusieurs fonctions qui vous permettent d'insérer, de remplacer et de supprimer des caractères selon vos besoins.

  • Une petite astuce concernant la modification : évitez d'appeler les méthodes plusieurs fois de suite.

    // 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) }
  • Au lieu de cela, regroupez toutes vos opérations dans un seul appel de modification.

    // Batch all operations in a single atomic edit:
    state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
    }
  • De cette façon, Compose ne crée qu'une seule instance de TextFieldBuffer, la modifie sur place, puis la synchronise une seule fois avec l'IME et le système d'instantanés.

  • Autre conseil : pour la plupart des opérations par lot courantes dont vous aurez besoin, nous proposons des fonctions d'extension sur TextFieldState.

    fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
    edit {
    replace(0, length, text)
    placeCursorAtEnd()
    }
    }
  • Vous n'avez donc pas besoin de regrouper ces opérations par lot.

  • Il vous suffit d'appeler l'une d'entre elles.

  • Par exemple, clearText et setTextAndPlaceCursorAtEnd.

    fun TextFieldState.clearText() {
    edit {
    delete(0, length)
    placeCursorAtEnd()
    }
    }

Filtrage et mise en forme des entrées

  • Maintenant, qu'en est-il du filtrage de l'entrée utilisateur ?

  • Par exemple, appliquer un nombre maximal de caractères ou n'accepter que des chiffres.

  • Dans l'ancienne API, le filtrage dans onValueChange s'exécutait une fois que le texte avait déjà été validé.

    onValueChange { newString ->
      // full diffing new String vs old String
    }
  • Par conséquent, le fait de rejeter ou de modifier cette saisie entrait en conflit avec le clavier.

  • De plus, le rappel onValueChange ne vous a fourni que la chaîne finale.

  • Pour comprendre ce qui avait changé, vous deviez donc effectuer une comparaison de chaînes complètes.

  • Au lieu de cela, dans la nouvelle surcharge, nous avons une transformation d'entrée.

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • Il intercepte donc l'entrée utilisateur avant qu'elle n'atteigne l'état du champ de texte et vous fournit les mêmes API TextFieldBuffer.

  • Voilà comment implémenter le filtrage des chiffres uniquement avec la transformation d'entrée. En gros, vous prenez simplement les modifications.

    // Intercept and filter input during typing
    inputTransformation = InputTransformation {
        val text = asCharSequence()
        if (!text.all { it.isDigit() }) {
            // Reject non-digit characters
            revertAllChanges()
        }
    }
  • Vous vérifiez s'il existe des caractères non numériques. Si c'est le cas, vous refusez l'entrée en appelant revertAllChanges sur ce TextFieldBuffer.

  • La principale fonctionnalité de cette transformation d'entrée est ChangeList.

  • Ainsi, au lieu de comparer les chaînes complètes, vous pouvez inspecter les plages de modification exactes, puis les modifier sur place ou rejeter complètement l'entrée si nécessaire.

  • Compose synchronisera automatiquement le clavier et le curseur pour vous.

  • Enfin, que se passe-t-il si vous souhaitez appliquer une mise en forme visuelle à votre entrée ?

  • Par exemple, vous souhaitez ajouter des parenthèses ou des tirets dans un numéro de carte de crédit ou un numéro de téléphone.

  • Oui.

  • Et vous ne voulez pas les stocker dans votre base de données, n'est-ce pas ?

  • Auparavant, vous avez utilisé l'API Visual Transformation.

  • Dans ce cas, vous deviez implémenter un mappage bidirectionnel de l'entrée brute vers la sortie visuelle, puis inversement.

  • Il était très difficile de le faire correctement, ce qui entraînait souvent des exceptions d'exécution.

  • Nous avons maintenant une transformation de sortie, qui est également basée sur le même TextFieldBuffer. Vous obtenez donc à peu près la même API.

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • Dans cet exemple de numéro de téléphone, pour insérer ces tirets à un index spécifique, il vous suffit d'appeler la fonction d'insertion de TextFieldBuffer, et le système calculera le mappage de décalage bidirectionnel pour vous.

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • Cela élimine les calculs manuels et réduit considérablement le nombre de plantages.

Hisser et migrer TextFieldState

  • Passons maintenant à la partie la plus importante : comment migrer et où héberger ce TextFieldState ?

  • En pratique, presque tous les cas d'utilisation entrent dans l'une des catégories que je vais aborder.

  • Si ce n'est pas le cas, cela signifie généralement que vous avez une architecture non conventionnelle, mais ces exemples vous guideront tout de même tout au long de votre migration.

  • Le cas le plus simple consiste à conserver le TextFieldState directement dans le composable où se trouve ce champ de texte.

    @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()) }) { }
        )
    }
  • C'est le cas lorsque votre champ de texte est un champ de saisie autonome, par exemple un champ de saisie de code promotionnel, et que vous n'avez besoin d'accéder à ce texte que lorsque l'utilisateur appuie sur le bouton d'envoi.

  • Votre ViewModel n'a donc pas besoin d'intercepter l'entrée utilisateur à chaque modification.

  • Il vous suffit donc d'appeler rememberTextFieldState localement. rememberTextFieldState vous aide également à restaurer l'état après l'arrêt du processus.

  • Ensuite, vous utilisez la transformation d'entrée pour le filtrage, vous récupérez le texte de cet état, l'objet TextFieldState, et lorsque l'utilisateur valide, vous le transmettez via le lambda.

  • Et si des éléments d'interface utilisateur frères, comme un compteur de caractères ou un bouton d'envoi, doivent également lire le texte ou le modifier ?

  • Cela nous amène à un cas où vous hissez l'objet TextFieldState vers une fonction composable commune, une fonction composable ancêtre commune.

    @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)
            }
        }
  • Comme TextFieldState est basé sur le système d'instantanés, le transmettre via l'ancêtre ne déclenche pas la recomposition des parents.

  • Seuls les emplacements où vous lisez réellement le texte de l'état seront mis à jour.

  • OK.

  • Ainsi, pour les cas d'utilisation d'UI complexes où vous avez besoin que votre TextFieldState, votre transformation d'entrée et votre transformation de sortie coordonnent l'état en interne, au lieu de les hisser séparément et d'essayer de les relier dans la fonction composable, utilisez un objet conteneur d'état.

    @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,
        )
    }
  • Imaginons que vous souhaitiez gérer un hashtag intégré.

  • TextFieldState contient un texte brut et la position du curseur.

  • La transformation d'entrée est chargée de détecter un hashtag, et vous pouvez également supprimer des tags atomiques via la transformation d'entrée.

  • La transformation de la sortie ajouterait un style visuel supplémentaire et pourrait également vous aider à supprimer le hashtag.

  • Ils sont tous regroupés dans un seul objet conteneur d'état, que j'appelle 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) { ... }
    
    }
  • Ils peuvent ensuite tous réutiliser les calculs intermédiaires, comme tagRanges dans ce cas.

  • Ensuite, il vous suffit de transmettre cet objet de conteneur d'état à votre composable.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
    
    @Composable
    fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
        BasicTextField(
            state = state.textFieldState,
            inputTransformation = state.inputTransformation,
            outputTransformation = state.outputTransformation,
        )
    }
  • Autre cas d'utilisation : lorsque votre logique métier a besoin d'accéder à ce texte pour la recherche instantanée ou une validation complexe, hissez ce champ de texte dans votre ViewModel.

  • Il est important de ne pas placer d'objet de champ de texte modifiable dans l'état d'UI de la classe de données immuable, car il est copié avec une méthode de copie, puis émis dans StateFlow à chaque modification.

    data class FormUiState(val username: TextFieldState)
  • TextFieldState est un objet modifiable de longue durée, soutenu par le système d'instantanés, et son objet de référence ne change jamais.

  • Votre StateFlow ne saura donc même pas que l'utilisateur est en train de taper, car la référence reste la même.

  • Pire encore, si vous créez une instance de l'état de l'UI et que vous allouez un nouveau TextFieldState, votre champ de saisie perdra tout son état.

  • Il peut s'agir de la sélection, de la position du curseur ou de l'historique des actions annulées/rétablies.

  • Faites plutôt de TextFieldState une propriété directement sur votre ViewModel.

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • Ne vous inquiétez pas de la règle "Pas d'UI dans ViewModel", car dans ce cas, TextFieldState ne fait pas référence à un objet View ou Context Android, ni au cycle de vie de Compose.

  • Il s'agit d'un simple objet conteneur d'état basé sur un instantané.

  • Dans ce cas, il n'y a pas de différence conceptuelle avec StateFlow.

  • N'oubliez pas que ViewModel n'a pas de rememberSaveable. Vous devez donc associer TextFieldState.Saver au champ de texte pour qu'il survive à l'arrêt du processus et restaure son état.

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • Maintenant, que se passe-t-il si votre équipe applique des règles strictes concernant les modules, où les ViewModels ne se trouvent que dans des modules Kotlin purs, et que vous ne pouvez pas ajouter cette dépendance compose-foundation ?

  • Ou, par exemple, vous migrez un écran ancien complexe et vous ne pouvez pas mettre à jour ou refactoriser l'état de l'UI aujourd'hui ?

  • Dans ce cas, utilisez l'approche conforme.

  • Vous conservez donc le TextFieldState dans le composable en tant que source unique de vérité pour ce champ de texte.

  • Ensuite, vous utilisez LaunchedEffect avec un snapshotFlow pour réintégrer ces modifications de texte dans votre ViewModel existant.

    @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)
    }
  • Votre ViewModel reste sans Compose, et comme les propriétés d'état de l'UI ne sont pas renvoyées au champ de texte à chaque modification, vous évitez toujours les bugs d'aller-retour asynchrones.

  • Oui, et attention à un piège courant ici.

    @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)
    }
  • Dans la plupart des cas, vous devez omettre le premier événement.

  • Cela évitera de dupliquer inutilement les requêtes de recherche ou d'écraser votre ViewModel avec la valeur initiale.

  • Si votre logique doit ensuite renvoyer ces modifications à l'état, par exemple lorsque l'utilisateur appuie sur le bouton d'envoi et que vous souhaitez effacer le texte, vous ne pouvez pas simplement définir une chaîne vide sur ViewModel du 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)
    }
  • À la place, vous devez émettre un événement de texte clair, puis la fonction composable qui contient cet objet TextFieldState appellera clearText sur cet objet.

  • Pour vous aider à effectuer cette transition, nous avons rédigé ce guide de migration.

  • Vous pouvez y accéder via ce lien.

  • Nous travaillons également sur une API supplémentaire pour faciliter la migration et l'observation des modifications générales de TextFieldState. Restez à l'écoute.

Édition de texte mis en forme et enrichi

  • Tout ce que nous avons vu jusqu'à présent (TextFieldState, TextFieldBuffer, transformation d'entrée et de sortie) n'a pas été conçu uniquement pour la saisie de texte brut.

  • C'est la base de notre nouvel ensemble d'API pour la modification de texte formaté.

  • Je laisse maintenant la parole à Haoyu, qui va vous en dire plus à ce sujet.

  • Très bien.

  • Merci.

  • Bonjour !

  • Je vais maintenant vous parler des nouvelles API que nous avons introduites pour la modification de texte enrichi.

  • Avant de nous intéresser aux nouvelles API, prenons un peu de recul et voyons comment fonctionne l'application de styles dans le texte statique.

  • La plupart d'entre vous connaissent donc déjà très bien 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)
  • C'est la méthode standard pour appliquer un style à un texte statique.

  • Nous utilisons buildAnnotatedString et appelons addStyle pour appliquer SpanStyle ou ParagraphStyle à certaines plages données.

  • Cela fonctionne bien pour le texte statique.

  • Lorsque nous avons examiné la modification de texte formaté, nous nous sommes d'abord demandé si nous pouvions simplement rendre AnnotatedString modifiable.

  • Mais nous avons rapidement réalisé que ce n'était pas si simple.

  • AnnotatedString est une structure de données immuable.

  • Mais dans le monde de l'édition, l'utilisateur tape constamment.

  • Et à chaque touche, à chaque frappe, nous devons allouer un nouvel objet AnnotatedString.

  • Chaque AnnotatedString comporte de nombreux AnnotatedString.Range.

  • Ces objets doivent donc également être recréés.

  • Il n'évolue pas très bien et entraîne des frais d'allocation importants.

  • Voici un exemple.

  • Lorsque nous insérons le mot au milieu, chaque style après celui-ci a décalé sa plage et est réattribué.

  • Cette méthode ne fonctionne clairement pas lorsque vous avez beaucoup de styles.

  • Nous avons donc introduit des API de style dédiées dans le TextFieldBuffer qu'Anastasia a présenté précédemment.

  • Elles sont très semblables aux API de style d'AnnotatedString.

  • Voyons comment ils fonctionnent.

  • Comme AnnotatedString.Builder, TextFieldBuffer dispose désormais de addStyle pour appliquer SpanStyle ou ParagraphStyle à une plage.

    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>
  • Vous pouvez également transmettre un paramètre ExpandPolicy, qui contrôle le comportement du style lorsque du texte est saisi juste aux limites du style.

  • Nous y reviendrons dans un instant.

  • Commençons par examiner le fonctionnement du suivi du style.

    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)
    }
  • Une fois que vous avez ajouté un style au TextFieldBuffer, sa plage est automatiquement mise à jour lorsque le texte est modifié.

  • Dans cet exemple, nous commençons par mettre du texte en gras.

  • Lorsque vous insérez du texte avant, la plage de style se déplace vers l'avant.

  • Lorsque vous insérez du texte à l'intérieur, la plage de texte s'agrandit.

  • Et lorsque nous supprimons du texte dans la plage, la plage de style se réduit en conséquence.

  • Si tout le texte de la plage de style est supprimé, le style est réduit et automatiquement supprimé du tampon.

  • Que se passe-t-il lorsque le texte est inséré juste aux limites d'une plage de styles ?

  • Le texte nouvellement inséré est-il couvert par le style ou non ?

  • Pour vous permettre de contrôler cela, nous avons introduit les API ExpandPolicy.

  • Passons-les en revue.

  • Le premier est 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")
  • Dans ce mode, l'insertion de texte à la limite n'étend pas les plages de style.

  • Ici, nous appliquons d'abord une plage en gras.

  • Si nous insérons du texte au début de la plage en gras, la plage de style se décale, mais ne s'étend pas.

  • De même, si nous insérons du texte à la fin de la plage en gras, la plage de style ne s'étend pas non plus.

  • Voici une démonstration de son fonctionnement dans un éditeur.

  • Passons à ExpandPolicy.AtStart.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtStart
        )
        insert(4, "quick ")
  • Comme son nom l'indique, l'insertion de texte au début de la plage étend la plage de style.

  • Ainsi, lorsque nous insérons du texte juste avant, la plage de style s'étend pour couvrir le texte nouvellement inséré.

  • Voici une autre démonstration de son fonctionnement.

  • Le troisième est ExpandPolicy.AtEnd.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtEnd
        )
        insert(length, " jumps")
  • Nous insérons donc du texte à la fin de la plage pour étendre la plage de style.

  • Il s'agit donc également du paramètre le plus courant pour les éditeurs.

  • Voici une démonstration de son fonctionnement.

  • Enfin, ExpandPolicy.AtBoth étend le style lorsque le texte est inséré au début et à la fin de la plage.

    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")
    }
  • Voici une autre démonstration.

  • Nous avons abordé toutes les API d'application de style.

  • En outre, nous avons également introduit de nouvelles API de requête telles que getSpanStyles et getParagraphStyles pour récupérer les styles actuellement appliqués dans une plage donnée.

    // 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>>
  • Voici deux versions des API de requête.

  • Sur TextFieldState, les API de requête de style sont en lecture seule.

  • Elles renvoient une liste d'objets AnnotatedString.Range immuables.

  • Mais dans TextFieldBuffer, getSpanStyles renvoie une liste d'objets TrackedRange nouvellement introduits, que je présenterai plus tard.

  • Toutes les requêtes de style tiennent donc compte de l'ordre.

  • Elles renvoient les plages de style exactement dans l'ordre dans lequel le style est appliqué.

  • Cela vous permet d'appliquer, tout comme AnnotatedString, un autre style par-dessus un style existant pour écraser les précédents.

  • Passons à l'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
  • Les objets TrackedRange sont renvoyés par les API addStyle ou getSpanStyles dans TextFieldBuffer.

  • Considérez-le comme un handle actif de la plage de styles dans le tampon.

  • Vous pouvez l'utiliser pour mettre à jour la plage de texte, ExpandPolicy ou le style lui-même.

  • Il possède également une propriété isValid pour vérifier si la plage de styles se trouve toujours dans le TextFieldBuffer.

  • En effet, le style peut être supprimé s'il est réduit pendant la modification ou s'il est supprimé délibérément par le removeStyle.

  • Voyons maintenant comment ces API s'articulent dans des cas d'utilisation réels.

Créer un éditeur WYSIWYG

  • Le premier cas d'utilisation est la création d'un éditeur WYSIWYG (What You See Is What You Get).

  • Dans cet exemple, nous avons implémenté la fonctionnalité du bouton "Gras".

  • Cliquer sur le bouton "Gras" permet d'activer ou de désactiver le style gras sur le texte sélectionné.

  • Examinons maintenant le code.

  • Ainsi, lorsque l'utilisateur appuie sur le bouton "Gras", nous appelons d'abord getSpanStyles sur une sélection pour trouver tous les styles qui se chevauchent.

    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())
        }
    }
  • Nous vérifions ensuite si l'intégralité de la sélection est déjà en gras ou non.

  • Ici, le mot sélectionné n'est pas en gras.

  • Comme il n'est pas en gras, nous addStyle à la plage sélectionnée.

  • Nous transmettons ExpandPolicy.AtEnd pour que l'utilisateur puisse continuer à saisir du texte en gras juste après ce mot.

  • Voyons maintenant ce qui se passe si la plage sélectionnée est déjà en gras.

    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)
        }
    }
  • Dans ce cas, nous devons supprimer le style gras de la plage sélectionnée.

  • Ici, le mot sélectionné par l'utilisateur se trouve dans l'expression plus longue en gras.

  • Comme le style gras s'étend avant la sélection, nous devons mettre à jour la plage de style, en laissant le mot sélectionné sans gras.

  • Pour ce faire, nous pouvons utiliser une API TrackedRange.

  • Dans ce cas précis, au lieu de supprimer ce style et de le recréer, nous pouvons simplement ajuster la plage de l'intervalle en gras pour qu'elle se termine avant la sélection à l'index 10.

  • Bien sûr, il s'agit d'une implémentation très simplifiée.

  • Pour une démonstration complète, vous pouvez consulter notre dépôt d'exemples Compose.

  • Examinons maintenant un style basé sur un motif.

  • Dans cette démonstration, lorsque l'utilisateur saisit des astérisques Markdown autour d'un mot, l'éditeur le convertit automatiquement en texte en gras au fur et à mesure de la saisie.

  • Voyons maintenant comment l'implémenter à l'aide de 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
                )
            }
        }
    }
  • Dans notre filtre d'entrée de transformation, nous trouvons tous les motifs correspondants sur le bouton.

  • Pour chaque correspondance, nous extrayons le contenu entre les doubles astérisques.

  • Dans cet exemple, il s'agit du mot "fox".

  • Ensuite, nous supprimons les astérisques et remplaçons le motif correspondant par son contenu.

  • Enfin, nous appelons addStyle pour appliquer le style gras au texte du contenu.

  • N'oubliez pas que InputTransformation enverra l'état final du champ de texte.

  • Cela signifie que le style gras est appliqué à l'état de manière persistante.

  • Enfin, examinons l'exemple de mise en surbrillance de la recherche.

  • Dans cet exemple, lorsque l'utilisateur saisit une requête de recherche, les mots correspondants dans les champs de texte sont mis en surbrillance, sans que l'état du champ de texte soit modifié.

  • Voyons maintenant comment cela est implémenté dans le OutputTransformation.

  • Lorsque vous mettez en surbrillance un mot recherché, vous ne devez jamais modifier l'état du texte.

  • OutputTransformation est l'endroit idéal pour implémenter ce cas d'utilisation.

  • Voyons maintenant l'implémentation réelle.

  • Nous recherchons d'abord les mots à mettre en surbrillance et trouvons toutes les correspondances.

    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)
            }
        }
    }
  • Notez que la requête est un état modifiable.

  • En effet, OutputTransformation est compatible avec les instantanés.

  • Lorsque cet état modifiable est mis à jour, le OutputTransformation est réappliqué automatiquement.

  • Ensuite, nous parcourons toutes les correspondances et appelons addStyle pour dessiner les surlignages jaunes sur la plage de correspondance.

  • Là encore, OutputTransformation ne modifie que le texte visuel, et les états du texte d'origine ne sont pas modifiés.

  • Voilà, nous avons fait le tour des cas d'utilisation.

  • Pour rappel, toutes les API présentées précédemment sont disponibles à partir de Compose 1.12.

Bibliothèque de texte de balisage à venir

  • Pour finir, je souhaite vous donner un aperçu du nouveau projet sur lequel nous travaillons actuellement.

  • Il s'agit d'une bibliothèque de texte de balisage pour Jetpack Compose, qui accepte les formats de texte enrichi tels que Markdown.

  • L'une des fonctionnalités les plus intéressantes que nous prenons en charge est l'analyse et l'affichage incrémentiels.

  • Il est donc très adapté à la modification en direct ou au streaming Markdown.

  • Voici un enregistrement de notre prototype en action. Vous pouvez voir que les zones qui s'estompent sont les parties de l'interface utilisateur qui sont mises à jour.

  • Si cela vous intéresse ou si vous avez un cas d'utilisation dans votre application, n'hésitez pas à nous contacter.

  • Et merci à tous.

Code

Extraits de code

Extrait de code Kotlin à 00:29 dans "Introduction to text editing in Compose"
TextField(state = rememberTextFieldState())
Extrait de code Kotlin à 00:29 dans "Introduction to text editing in Compose"
OutlinedTextField(state = rememberTextFieldState())
Extrait de code Kotlin à 00:34 dans "Introduction à l'édition de texte dans Compose"
BasicTextField(state = rememberTextFieldState())
Extrait de code Kotlin à 00:44 dans "Introduction to text editing in Compose"
SecureTextField(state = rememberTextFieldState())
Extrait de code Kotlin à 00:58 dans "Introduction à l'édition de texte dans Compose"
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Extrait Kotlin à 01:28 dans "Introduction to text editing in Compose"
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

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

}
Extrait de code Kotlin à 10:30 dans "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,
    )
}
Extrait Kotlin à 10:51 dans "Hoisting and migrating TextFieldState"
data class FormUiState(val username: TextFieldState)
Extrait de code Kotlin à 11:38 dans "Hoisting and migrating TextFieldState"
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Extrait Kotlin à 12:09 dans "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> = ...
}
Extrait de code Kotlin à 12:55 dans "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)
}
Extrait de code Kotlin à 13:21 dans "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)
}
Extrait de code Kotlin à 13:39 dans "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)
}
Extrait de code Kotlin à 15:09 dans "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)
Extrait Kotlin à 16:36 dans "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>
Extrait de code Kotlin à 16:57 dans "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)
}
Extrait de code Kotlin à 17:52 dans "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")
Extrait de code Kotlin à 18:22 dans "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 ")
Extrait de code Kotlin à 18:40 dans "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")
Extrait Kotlin à 18:56 dans "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")
}
Extrait de code Kotlin à 19:14 dans "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>>
Extrait de code Kotlin à 20:03 dans "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
Extrait de code Kotlin à 21:01 dans "Créer un éditeur 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())
    }
}
Extrait de code Kotlin à 21:26 dans "Créer un éditeur WYSIWYG"
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)
    }
}
Extrait de code Kotlin à 22:27 dans "Créer un éditeur 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
            )
        }
    }
}
Extrait de code Kotlin à 23:42 dans "Créer un éditeur 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)
        }
    }
}