View in English

  • Global Nav Open Menu Global Nav Close Menu
  • Apple Developer
Search
Cancel
  • Apple Developer
  • News
  • Discover
  • Design
  • Develop
  • Distribute
  • Support
  • Account
Only search within “”

Quick Links

5 Quick Links

Vidéos

Ouvrir le menu Fermer le menu
  • Collections
  • Sujets
  • Toutes les vidéos
  • À propos

Plus de vidéos

  • À propos
  • Résumé
  • Transcription
  • Code
  • Code-along : Préparation d’une expérience de texte enrichie dans SwiftUI avec AttributedString

    Apprenez à créer une expérience de texte enrichi avec l'API TextEditor de SwiftUI et AttributedString. Découvrez comment activer l'édition de texte enrichi, créer des contrôles personnalisés qui modifient le contenu de votre éditeur et personnaliser les options de mise en forme disponibles. Explorez les fonctionnalités avancées d'AttributedString qui vous permettent de créer des expériences d'édition de texte optimales.

    Chapitres

    • 0:00 - Introduction
    • 1:15 - TextEditor et AttributedString
    • 5:36 - Créez des contrôles personnalisés
    • 22:02 - Définissez votre format de texte
    • 34:08 - Étapes suivantes

    Ressources

    • AttributedTextFormatting
    • AttributedTextSelection
    • Building rich SwiftUI text experiences
    • Character
      • Vidéo HD
      • Vidéo SD

    Vidéos connexes

    WWDC25

    • Améliorez l’expérience multilingue de votre app

    WWDC22

    • Get it right (to left)

    WWDC21

    • What's new in Foundation
  • Rechercher dans cette vidéo…

    Bonjour, je m’appelle Max, ingénieur dans l’équipe SwiftUI, et moi Jeremy, ingénieur dans l’équipe des bibliothèques standard Swift ! Nous sommes tous deux ravis de vous expliquer comment créer des expériences axées sur l’édition de texte enrichi en utilisant la puissance de SwiftUI et d’AttributedString. Avec l’aide de Jeremy, j’aborderai tous les aspects importants des expériences de texte enrichi dans SwiftUI : nous commencerons par la mise à niveau de TextEditor pour prendre en charge le texte enrichi à l’aide d’AttributedString. Ensuite, je créerai des commandes personnalisées, qui améliorent mon éditeur avec des fonctionnalités uniques. Et enfin, je créerai ma propre définition de formatage de texte pour que mon éditeur, et son contenu, soient toujours superbes !

    Maintenant, bien que je sois ingénieur le jour, je suis aussi... un cuisinier à domicile ayant pour mission de préparer le croissant parfait ! Récemment, j’ai donc concocté un petit éditeur de recettes pour garder une trace de toutes mes tentatives !

    Il contient une liste de recettes à gauche, un TextEditor pour modifier le texte de la recette au milieu et une liste d’ingrédients dans l’inspecteur à droite. J’aimerais faire ressortir les parties les plus importantes du texte de ma recette, afin de pouvoir les repérer rapidement quand que je cuisine. Aujourd’hui, je vais rendre cela possible en mettant à jour cet éditeur pour qu’il prenne en charge le texte enrichi !

    Voici la vue de l’éditeur, implémentée à l’aide de l’API TextEditor de SwiftUI. Comme l’indique le type de mon état de texte, String, il ne prend actuellement en charge que le texte brut. Je peux simplement remplacer String par AttributedString, ce qui augmente considérablement les possibilités offertes par la vue.

    Maintenant que le texte enrichi est pris en charge, je peux utiliser l’interface du système pour activer ou désactiver la mise en gras et appliquer toutes sortes d’autres formatages, comme l’augmentation de la taille de police.

    Je peux désormais insérer des Genmoji à partir du clavier. Grâce à ses attributs sémantiques, le nouveau TextEditor peut également prendre en charge le mode sombre et les polices dynamiques !

    En fait, TextEditor prend en charge le gras, l’italique, le soulignement, le barré, les polices personnalisées, la taille des points, les couleurs de premier plan et d’arrière-plan, le crénage, le suivi, le décalage de la ligne de base, les Genmoji et... d’importants aspects du style des paragraphes ! La hauteur de ligne, l’alignement du texte et le sens de l’écriture de base sont également disponibles en tant qu’attributs AttributedString indépendants pour SwiftUI.

    Tous ces attributs sont cohérents avec le texte non modifiable de SwiftUI, de sorte qu’il vous suffit de prendre le contenu d’un TextEditor et de l’afficher avec du texte plus tard ! Tout comme avec Text, TextEditor remplace la valeur par défaut calculée à partir de l’environnement des AttributedStringKeys par une valeur nulle. Jeremy, je dois être honnête. J’ai jusqu’à présent réussi à travailler avec les AttributedStrings, mais j’aurais vraiment besoin d’une remise à niveau pour m’assurer de la solidité de mes connaissances avant de me lancer dans l’élaboration de commandes. Il faut aussi que je prépare cette pâte à croissant pour plus tard. Accepterais-tu de me donner un cours de rattrapage pendant que je m’en occupe ? Bien sûr Max ! Pendant que tu te mets à préparer la pâte à croissant, je vais prendre quelques instants pour discuter des principes de base de la chaîne AttributedString, lesquels te seront utiles lorsque tu travailleras avec des éditeurs de texte enrichi. En bref, les AttributedStrings contiennent une séquence de caractères et une séquence sur les exécutions d’attributs. Par exemple, cette AttributedString stocke le texte « Hello ! Who’s ready to get cooking? ». Elle comporte également trois exécutions d’attributs : une première exécution avec juste une police, une deuxième avec une couleur de premier plan supplémentaire et une finale avec seulement l’attribut de police.

    Le type AttributedString s’intègre parfaitement aux autres types Swift que vous utilisez dans vos apps. Il s’agit d’un type de valeur, et tout comme String de la bibliothèque standard, il stocke son contenu en utilisant l’encodage UTF-8. AttributedString est également conforme à de nombreux protocoles Swift courants tels que Equatable, Hashable, Codable et Sendable. Les SDK d’Apple sont livrés avec divers attributs prédéfinis - comme ceux que Max a partagés précédemment - regroupés dans des champs d’application d’attributs. AttributedString gère également les attributs personnalisés définis dans votre app pour un style personnalisé selon votre interface.

    Je vais créer ma chaîne AttributedString de tout à l’heure en utilisant quelques lignes de code. Tout d’abord, je crée une AttributedString avec le début de mon texte. Ensuite, je crée la chaîne « cooking », je définis sa couleur de premier plan sur orange et je l’ajoute au texte. Ensuite, je termine la phrase avec le point d’interrogation final. Enfin, j’attribue à l’ensemble du texte la police largeTitle. Maintenant, je suis prêt à l’afficher dans mon interface sur mon appareil !

    Pour en savoir plus sur les principes de création de base des AttributedStrings, ainsi que sur la création et l’utilisation de vos propres attributs personnalisés et champs d’application d’attributs, consultez la séance « What’s new in Foundation » de la WWDC 2021.

    Max, on dirait que tu as fini de faire la pâte ! Es-tu prêt à examiner en détail l’utilisation d’AttributedString dans ton app de recettes ? Bien sûr, Jeremy, c’est exactement ce dont j’ai besoin. Je voulais vraiment offrir de meilleures commandes pour relier mon éditeur de texte au reste de mon app. Je veux créer un bouton qui me permette d’ajouter des ingrédients à la liste dans l’inspecteur à droite, mais sans avoir à retaper manuellement le nom de l’ingrédient. Par exemple, je veux juste pouvoir sélectionner le mot « beurre » qui est déjà dans ma recette et le marquer comme ingrédient en appuyant simplement sur un bouton.

    Mon inspecteur définit déjà une clé de préférence que je peux utiliser dans mon éditeur pour suggérer un nouvel ingrédient pour la liste.

    La valeur que je transmets au modificateur de la vue préférée remontera dans la hiérarchie des vues et pourra être lue via le nom « NewIngredientPreferenceKey » par n’importe quelle vue utilisant mon éditeur de recettes.

    Permets-moi de définir une propriété calculée pour cette valeur sous le corps de ma vue.

    Tout ce que j’ai besoin de fournir pour la suggestion est le nom, en tant qu’AttributedString. Bien sûr, je ne veux pas simplement suggérer le texte entier figurant dans mon éditeur. Au lieu de cela, je veux suggérer le texte actuellement sélectionné, comme je l’ai montré avec « beurre ». TextEditor communique ce qui est sélectionné via l’argument de sélection facultatif.

    Permets-moi de lier cela à un état local de type AttributedTextSelection.

    Ok, maintenant que j’ai tout le contexte requis dans ma vue, retournons à la propriété calculant la suggestion d’ingrédient.

    Maintenant, j’ai besoin d’obtenir le texte qui est sélectionné.

    Essayons de mettre le texte en indice avec le résultat de cette fonction d’indice sur la sélection.

    Hmm, cela ne semble pas être le bon type.

    Il renvoie AttributedTextSelection.Indices. Laisse-moi vérifier.

    Oh, c’est intéressant, je n’ai qu’une seule sélection, mais le deuxième cas du type Indices est représenté par un ensemble de plages.

    Jeremy, peux-tu m’expliquer la raison pendant que je plie ma pâte à croissants ? Ha, c’est drôle. Je plie aussi - dans l’attente de goûter ces savoureux croissants. Mais ne t’inquiète pas Max, je vais t’expliquer pourquoi cette API n’utilise pas le type Range que tu attendais. Pour expliquer pourquoi cette API utilise un RangeSet et non un seul Range, je vais m’intéresser aux sélections d’AttributedString. J’expliquerai comment plusieurs plages peuvent former des sélections et je montrerai comment utiliser les RangeSets dans ton code. Tu as probablement déjà utilisé une plage unique dans les API d’AttributedString ou d’autres API de collection. Une plage unique te permet de découper une partie d’une chaîne AttributedString et d’effectuer une action sur ce seul bloc. Par exemple, une AttributedString fournit des API qui te permettent d’appliquer rapidement un attribut à une portion de texte en une fois. J’ai utilisé l’API .range(of:) pour trouver la plage du texte « cooking » dans ma chaîne AttributedString. Ensuite, j’utilise l’opérateur d’indice pour découper l’AttributedString avec cette plage et faire apparaître le mot entier « cooking » en orange.

    Toutefois, une chaîne AttributedString découpée avec une seule plage ne suffit pas pour représenter les sélections dans un éditeur de texte qui fonctionne pour toutes les langues. Par exemple, je pourrais utiliser cette app de recettes pour sauvegarder ma recette de Sufganiyot que je prévois de cuisiner pendant les vacances et qui contient du texte en hébreu. Ma recette dit : « Put the Sufganiyot in the pan », utilisant ainsi le texte anglais pour les instructions et le texte hébreu pour le nom traditionnel du plat. Dans l’éditeur de texte, je sélectionne une partie du mot « Sufganiyot » et le mot « in » en une seule sélection. Cependant, il s’agit en fait de plusieurs plages dans l’AttributedString ! L’anglais étant une langue qui se lit de gauche à droite, l’éditeur présente la phrase visuellement de gauche à droite. Cependant, la partie en hébreu, Sufganiyot, est présentée dans la direction opposée puisque l’hébreu est une langue qui se lit de droite à gauche. Bien que la nature bidirectionnelle de ce texte impacte la disposition visuelle à l’écran, l’AttributedString continue de stocker tout le texte dans un ordre cohérent. Cet ordre divise ma sélection en deux plages : le début du mot « Sufganiyot » et le mot « in », à l’exclusion de la fin du texte en hébreu. C’est pourquoi le type de sélection de texte SwiftUI utilise plusieurs plages plutôt qu’une seule.

    Pour en savoir plus sur la localisation de votre app pour un texte bidirectionnel, consultez la séance « Get it right (to left) » de la WWDC 2022 et la séance « Enhance your app’s multilingual experience » de cette année.

    Pour prendre en charge ces types de sélections, l’AttributedString gère le découpage avec un RangeSet, le type que Max a remarqué plus tôt dans l’API de sélection. Tout comme vous pouvez découper une AttributedString avec une plage singulière, vous pouvez également la découper avec un RangeSet pour produire une chaîne secondaire discontinue. Dans le cas présent, j’ai créé un RangeSet en utilisant la fonction .indices(where:) sur la vue de caractères pour identifier toutes les majuscules de mon texte. Si vous définissez le bleu comme couleur de premier plan pour cette partie, toutes les majuscules deviendront bleues, les autres caractères restant inchangés. SwiftUI fournit également un indice équivalent qui permet directement de découper une AttributedString avec une sélection. Hé Max, si tu as fini de plier cette belle pâte à croissant, je pense qu’en utilisant l’API d’indice, laquelle accepte une sélection, tu pourrais résoudre l’erreur de compilation dans ton code ! Je vais essayer ! Je peux juste placer du texte en indice directement avec la sélection, puis transformer la chaîne AttributedSubstring discontinue en une nouvelle chaîne AttributedString.

    Formidable ! Désormais, lorsque je l’exécute sur l’appareil et que je sélectionne le mot « beurre », SwiftUI appelle automatiquement ma propriété newIngredientSuggestion pour calculer la nouvelle valeur, qui est répercutée sur le reste de mon app. Mon inspecteur ajoute ensuite automatiquement la suggestion au bas de la liste des ingrédients. De là, je peux l’ajouter à la liste des ingrédients d’un simple clic ! De telles fonctionnalités peuvent transformer un éditeur en une expérience magnifique !

    Je suis vraiment content de cet ajout, mais avec tout ce que Jeremy m’a montré jusqu’à présent, je pense pouvoir aller encore plus loin ! Je veux mieux visualiser les ingrédients dans le texte lui-même ! La première chose dont j’ai besoin pour cela est un attribut personnalisé qui marque une plage de texte comme étant un ingrédient. Définissons cela dans un nouveau fichier.

    La valeur de cet attribut sera l’ID de l’ingrédient auquel il fait référence. Revenons maintenant à la propriété qui calcule l’IngredientSuggestion dans le fichier RecipeEditor.

    IngredientSuggestion me permet de fournir une fermeture comme deuxième argument.

    Cette fermeture est appelée lorsque j’appuie sur le bouton plus et que l’ingrédient est ajouté à la liste. Je vais utiliser cette fermeture pour modifier le texte de l’éditeur, en marquant les occurrences du nom avec mon attribut Ingredient. Je reçois l’ID de l’ingrédient nouvellement créé transmis à la fermeture.

    Ensuite, je dois trouver toutes les occurrences du nom de l’ingrédient suggéré dans mon texte.

    Je peux le faire en appelant ranges(of:) dans la vue des caractères d’AttributedString.

    Maintenant que j’ai les plages, il me suffit de mettre à jour la valeur de mon attribut d’ingrédient pour chaque plage !

    Ici, j’utilise un nom abrégé pour l’IngredientAttribute que j’avais déjà défini.

    Essayons !

    Je ne m’attends à rien de nouveau ici. Après tout, mon attribut personnalisé n’a aucune mise en forme associée. Je sélectionne « levure » et j’appuie sur le bouton plus !

    Attendez, qu’est-ce que c’est ? ! Mon curseur était en haut, pas à la fin ! Réessayons !

    Je sélectionne « sel », j’appuie sur le bouton plus et ma sélection saute à la fin ! Jeremy, je dois étaler ma pâte à croissant, donc je ne peux pas déboguer ça tout de suite... Sais-tu pourquoi ma sélection est réinitialisée ? Ce n’est pas une expérience enviable pour les cuisiniers qui utilisent ton app ! Pourquoi ne pas commencer à étaler la pâte, pendant que j’examine ce comportement inattendu.

    Afin de démontrer ce qui se passe ici et comment y remédier, j’expliquerai les détails des indices AttributedString qui forment les plages et les sélections de texte utilisées par un TextEditor riche.

    AttributedString.Index représente un emplacement unique dans ton texte. Pour prendre en charge sa conception performante, AttributedString stocke son contenu dans une arborescence, et ses indices stockent les chemins d’accès au sein de cette arborescence. Étant donné que ces indices constituent les éléments de base des sélections de texte dans SwiftUI, le comportement de sélection inattendu dans l’app découle de la manière dont les indices AttributedString se comportent dans ces arborescences. Tu dois garder à l’esprit deux points essentiels lorsque tu utilises des indices AttributedString. Tout d’abord, toute mutation d’un AttributedString invalide tous ses indices, même ceux qui ne sont pas dans les limites de la mutation. Les recettes ne donnent jamais de bons résultats lorsque l’on utilise des ingrédients périmés, et je peux t’assurer que tu penseras la même chose à propos de l’utilisation d’anciens indices avec une chaîne AttributedString. Deuxièmement, tu ne dois utiliser les indices qu’avec la chaîne AttributedString à partir de laquelle ils ont été créés.

    Je vais maintenant explorer les indices de l’exemple AttributedString que j’ai créés plus tôt pour expliquer leur fonctionnement ! Comme déjà mentionné, AttributedString stocke son contenu dans une arborescence, et j’ai ici un exemple simplifié de cette structure. L’utilisation d’une arborescence permet d’améliorer les performances et d’éviter la copie de nombreuses données lors de la modification du texte.

    AttributedString.Index référence un texte en stockant un chemin dans l’arborescence jusqu’à l’emplacement référencé. Ce chemin stocké permet à la chaîne AttributedString de localiser rapidement un texte spécifique à partir d’un indice. Cela signifie également que l’indice contient des infos sur la disposition de l’arborescence entière d’AttributedString. Lorsque vous modifiez une AttributedString, il se peut que la disposition de l’arborescence soit modifiée. Cela invalide tous les chemins précédemment enregistrés, même si la destination de cet indice existe toujours dans le texte.

    En outre, même si deux AttributedStrings ont le même texte et le même contenu d’attribut, leurs arborescences peuvent avoir des dispositions différentes, ce qui rend leurs indices incompatibles entre eux.

    L’utilisation d’un indice pour parcourir ces arborescences à la recherche d’infos implique d’utiliser l’indice figurant dans l’une des vues de la chaîne AttributedString. Bien que les indices soient spécifiques à une chaîne AttributedString particulière, vous pouvez les utiliser dans n’importe quelle vue à partir de cette chaîne. Foundation fournit des vues sur les caractères, ou groupes de graphèmes, du contenu textuel, les scalaires Unicode individuels qui composent chaque caractère et les exécutions d’attributs de la chaîne.

    Pour en savoir plus sur les différences entre les vues des caractères et des scalaires Unicode, consultez la documentation du développeur Apple relative au type de caractère Swift.

    Il se peut également que vous souhaitiez accéder à des contenus de niveau inférieur lors de l’interfaçage avec d’autres types de chaînes de caractères qui n’utilisent pas le type Character de Swift, comme NSString. AttributedString fournit désormais des vues sur les scalaires UTF-8 et UTF-16 du texte. Ces deux vues partagent toujours les mêmes indices que toutes les vues existantes.

    Après avoir parlé en détail des indices et des sélections, je vais revenir sur le problème rencontré par Max avec l’app de recettes. La fermeture onApply dans IngredientSuggestion modifie la chaîne attribuée, mais elle ne met pas à jour les indices dans la sélection ! SwiftUI détecte que ces indices ne sont plus valides et déplace la sélection à la fin du texte pour éviter que l’app ne se bloque. Pour résoudre ce problème, utilisez les API AttributedString pour mettre à jour vos indices et vos sélections lors de la modification du texte.

    Voici un exemple simplifié de code qui présente le même problème que l’app de recettes. Tout d’abord, je trouve la plage du mot « cooking » dans mon texte. Ensuite, j’attribue à la plage du mot « cooking » une couleur de premier plan orange et j’insère également le mot « chef » dans ma chaîne pour thématiser davantage la recette.

    La modification de mon texte peut modifier la disposition de l’arborescence de mon AttributedString.

    L’utilisation de la variable cookingRange après avoir modifié ma chaîne n’est pas valide. Il se peut même que l’app plante. Au lieu de cela, AttributedString fournit une fonction de transformation qui choisit une plage, ou un tableau de plages, et une fermeture qui transforme la chaîne AttributedString fournie sur place.

    À la fin de la fermeture, la fonction de transformation met à jour la plage fournie avec de nouveaux indices pour s’assurer que l’on peut correctement utiliser la plage dans la chaîne AttributedString résultante. Bien que le texte ait pu se déplacer dans l’AttributedString, la plage pointe toujours vers le même emplacement sémantique - dans ce cas, le mot « cooking ». SwiftUI fournit également une fonction équivalente qui met à jour une sélection au lieu d’une plage.

    Ouah, Max, ces croissants s’annoncent très réussis ! Si tu es prêt à revenir à ton app, je pense que l’utilisation de cette nouvelle fonction de transformation t’aidera également à mettre ton code en forme ! Merci ! C’est exactement ce que je cherchais ! Voyons si je peux appliquer cela au code.

    Premièrement, je ne devrais pas insérer une telle boucle sur les plages. Lorsque j’atteins la dernière plage, le texte a été modifié de nombreuses fois et les indices sont obsolètes. Je peux éviter complètement ce problème en convertissant d’abord mes Ranges en RangeSet.

    Ensuite, je peux simplement découper le texte et supprimer la boucle.

    De cette façon, tout se fait en un seul changement et je n’ai pas besoin de mettre à jour les plages restantes après chaque modification.

    Deuxièmement, à côté des plages que je veux modifier, il y a aussi la sélection représentant la position de mon curseur. Je dois toujours m’assurer qu’elle correspond au texte transformé. Je peux le faire en utilisant la surcharge transform(updating:) de SwiftUI sur AttributedString.

    Extra, ma sélection est maintenant mise à jour juste au moment où le texte change !

    Voyons si cela a fonctionné ! Je peux sélectionner « lait », il apparaît dans la liste et, lorsque je l’ajoute, ma sélection reste intacte ! Pour vérifier, lorsque j’appuie sur Commande+B sur le clavier, je peux voir le mot « lait » se mettre en gras - comme prévu !

    Maintenant que j’ai toutes les infos dans le texte de ma recette, je veux mettre en valeur les ingrédients avec un peu de couleur ! Heureusement, TextEditor fournit un outil pour cela : le protocole de définition de formatage de texte attribué. Une définition de formatage de texte personnalisée est structurée autour des AttributedStringKeys auxquelles votre éditeur de texte répond et des valeurs qu’elles peuvent avoir. J’ai déjà déclaré un type conforme au protocole AttributedTextFormattingDefinition ici.

    Par défaut, le système utilise le champ d’application SwiftUIAttributes, sans aucune contrainte sur les valeurs des attributs.

    Dans le champ d’application de mon éditeur de recettes, je ne veux autoriser que la couleur de premier plan, les Genmoji et mon attribut d’ingrédient personnalisé.

    De retour dans mon éditeur de recettes, je peux utiliser le modificateur attributedTextFormattingDefinition pour transmettre ma définition personnalisée au TextEditor de SwiftUI.

    Avec ce changement, mon TextEditor autorisera n’importe quel ingrédient, n’importe quel Genmoji et n’importe quelle couleur de premier plan.

    Tous les autres attributs prendront désormais leur valeur par défaut. Notez que vous pouvez toujours changer la valeur par défaut de l’ensemble de l’éditeur en modifiant l’environnement. Sur la base de ce changement, TextEditor a déjà apporté quelques modifications importantes à l’interface de formatage du système. Il n’offre plus de commandes pour modifier l’alignement, la hauteur de ligne ou les propriétés de police, car les AttributedStringKeys respectives ne sont pas dans mon champ d’application. Cependant, je peux toujours utiliser la commande des couleurs pour appliquer des couleurs arbitraires à mon texte, même si ces couleurs n’ont pas nécessairement de sens.

    Oh non, le lait a disparu !

    Je veux vraiment que seuls les ingrédients soient surlignés en vert et que tout le reste utilise la couleur par défaut. Je peux utiliser le protocole AttributedTextValueConstraint de SwiftUI pour mettre en œuvre cette logique.

    Revenons au fichier RecipeFormattingDefinition et déclarons la contrainte.

    Pour me conformer au protocole AttributedTextValueConstraint, je spécifie d’abord le champ d’application de l’AttributedTextFormattingDefinition à laquelle il appartient, puis l’AttributedStringKey que je veux contraindre, dans mon cas l’attribut de couleur de premier plan. La logique réelle de contrainte de l’attribut réside dans la fonction de contrainte. Dans cette fonction, j’attribue à l’AttributeKey, la couleur de premier plan, une valeur que je considère comme valide.

    Dans mon cas, la logique dépend entièrement de la définition ou non de l’attribut d’ingrédient.

    Si tel est le cas, la couleur de premier plan doit être verte, sinon elle doit être nulle, ce qui indique que TextEditor doit remplacer la couleur par défaut.

    Maintenant que j’ai défini la contrainte, il ne me reste plus qu’à l’ajouter au corps de l’AttributedTextFormattingDefinition.

    À partir de là, SwiftUI s’occupe de tout le reste. TextEditor applique automatiquement la définition et ses contraintes à n’importe quelle partie du texte avant qu’il n’apparaisse à l’écran.

    Tous les ingrédients sont verts maintenant !

    Il est intéressant de noter que TextEditor a désactivé son contrôle de la couleur, bien que la couleur de premier plan fasse partie du champ d’application de ma définition de formatage. Cela a du sens compte tenu de la contrainte IngredientsAreGreen que j’ai ajoutée. La couleur de premier plan dépend désormais uniquement du fait que le texte est ou non marqué avec l’attribut ingrédient. TextEditor examine automatiquement AttributedTextValueConstraints pour déterminer si une modification potentielle est valide pour la sélection actuelle. Par exemple, je pourrais essayer de redéfinir la couleur de premier plan de « lait » en blanc. L’exécution ultérieure de ma contrainte IngredientsAreGreen ramènerait la couleur de premier plan au vert, de sorte que TextEditor saurait qu’il ne s’agit pas d’une modification valide et désactiverait la commande. Ma contrainte de valeur sera également appliquée au texte que je colle dans mon éditeur. Lorsque je copie un ingrédient à l’aide de Commande+C et que je le colle à nouveau à l’aide de Commande+V, mon attribut d’ingrédient personnalisé est préservé. Avec CodableAttributedStringKeys, cela peut même fonctionner entre les TextEditor de différentes apps, à condition que les deux apps répertorient l’attribut dans leur AttributedTextFormattingDefinition.

    C’est très bien, mais il y a encore des choses à améliorer : lorsque mon curseur se trouve à la fin de l’ingrédient « lait », je peux supprimer des caractères ou continuer à taper et le texte semble normal. Cela donne l’impression qu’il s’agit simplement d’un texte vert, et non d’un ingrédient portant un certain nom. Pour que cela semble correct, je ne veux pas qu’au terme de son exécution l’attribut d’ingrédient s’étende au fur et à mesure que je tape. Et j’aimerais que la couleur de premier plan se réinitialise en une fois pour le mot entier si je le modifie.

    Jeremy, si je te promets que je te donnerai un croissant supplémentaire plus tard, m’aideras-tu à mettre cela en place ? Hmm... Je ne suis pas sûr qu’un seul suffise, mais si tu m’en donnes plusieurs, c’est d’accord, Max ! Pendant que tu vas mettre ces croissants au four, je vais expliquer quelles API pourraient aider à résoudre ce problème. Avec les contraintes de définition de formatage présentées par Max, vous pouvez contraindre les attributs et les valeurs spécifiques que chaque éditeur de texte peut afficher. Pour résoudre ce nouveau problème avec l’éditeur de recettes, le protocole AttributedStringKey fournit des API supplémentaires pour contraindre la façon dont les valeurs d’attribut sont modifiées lors des changements apportés à n’importe quelle chaîne AttributedString. Lorsque les attributs déclarent des contraintes, AttributedString maintient toujours la cohérence des attributs entre eux et le contenu textuel pour éviter un état inattendu avec un code plus simple et plus performant. Je vais présenter quelques exemples pour expliquer à quel moment utiliser ces API pour vos attributs. Tout d’abord, j’aborderai les attributs dont les valeurs sont couplées à d’autres contenus de la chaîne AttributedString, tels que l’attribut de vérification orthographique.

    Ici, j’ai un attribut de vérification orthographique qui indique que le mot « redy » est mal orthographié au moyen d’un soulignement rouge en pointillé. Après avoir vérifié l’orthographe de mon texte, je dois m’assurer que l’attribut de vérification orthographique reste uniquement appliqué au texte que j’ai déjà validé. Cependant, si je continue à taper dans mon éditeur de texte, tous les attributs du texte existant sont par défaut hérités par le texte inséré. Ce n’est pas ce que je veux pour un attribut de vérification orthographique, je vais donc ajouter une nouvelle propriété à mon AttributedStringKey pour corriger cela. En déclarant une propriété inheritedByAddedText sur mon type AttributedStringKey avec la valeur « false », tout texte ajouté n’héritera pas de la valeur de cet attribut.

    Maintenant, lors de l’ajout d’un nouveau texte à ma chaîne, ce dernier ne contiendra pas l’attribut de vérification orthographique, car je n’ai pas encore vérifié l’orthographe de ces mots. Malheureusement, j’ai trouvé un autre problème avec cet attribut. Maintenant, lorsque j’ajoute du texte au milieu d’un mot qui a été marqué comme mal orthographié, l’attribut affiche une rupture gênante dans la ligne rouge sous le texte ajouté. Étant donné que mon app n’a pas encore vérifié si ce mot est mal orthographié, ce que je veux vraiment, c’est que l’attribut soit supprimé de ce mot pour éviter les infos obsolètes dans mon interface.

    Pour résoudre ce problème, je vais ajouter une autre propriété à mon type AttributedStringKey : la propriété invalidationConditions. Cette propriété déclare les situations dans lesquelles une exécution de cet attribut doit être supprimée du texte. AttributedString fournit des conditions concernant la modification du texte et d’attributs spécifiques, et les clés d’attribut peuvent invalider un nombre illimité de conditions. Dans ce cas, je dois supprimer cet attribut chaque fois que le texte de l’exécution de l’attribut change. J’utiliserai donc la valeur « textChanged ».

    Désormais, l’insertion de texte au milieu d’une exécution d’attribut invalidera l’attribut sur l’ensemble de l’exécution, ce qui évitera la survenue de cet état incohérent dans mon interface. Je pense que ces deux API pourraient contribuer à conserver l’attribut ingrédient valide dans l’app de Max ! Pendant que Max finit de s’occuper du four, je vais présenter une autre catégorie d’attributs : les attributs qui exigent des valeurs cohérentes d’une section à l’autre du texte. Par exemple, un attribut d’alignement de paragraphe.

    Je peux appliquer des alignements différents à chaque paragraphe de mon texte, mais un seul mot ne peut pas utiliser un alignement différent du reste du paragraphe. Pour appliquer cette exigence lors des mutations de AttributedString, je déclare une propriété runBoundaries sur mon type AttributedStringKey. Foundation prend en charge la restriction des limites d’exécution aux bords du paragraphe ou d’un caractère spécifié. Dans ce cas, je définirai cet attribut comme étant contraint aux limites du paragraphe pour exiger qu’il ait une valeur cohérente du début à la fin d’un paragraphe.

    Maintenant, cette situation devient impossible. Si j’applique une valeur d’alignement à gauche à un seul mot d’un paragraphe, AttributedString étend automatiquement l’attribut à toute la plage du paragraphe. En outre, lorsque j’énumère l’attribut d’alignement, AttributedString énumère chaque paragraphe individuel, même si deux paragraphes consécutifs contiennent la même valeur d’attribut. Les autres limites d’exécution se comportent de la même manière : AttributedString étend les valeurs d’une limite à l’autre et garantit l’interruption des exécutions énumérées à chaque limite d’exécution.

    Max, ces croissants sentent divinement bon ! Si les croissants sont déjà prêts, penses-tu que certaines de ces API pourraient compléter ta définition de formatage pour obtenir le comportement que tu souhaites pour ton attribut personnalisé ? Cela semble être l’ingrédient secret dont j’avais besoin ! Les croissants sont tous mis au four, je peux donc essayer cela tout de suite !

    Dans mon IngredientAttribute personnalisé ici, je vais implémenter l’exigence facultative inheritedByAddedText pour avoir la valeur false. De cette façon, si je tape après un ingrédient, il ne s’étendra pas.

    Deuxièmement, j’implémenterai invalidationConditions avec textChanged, de sorte que si je supprime des caractères dans un ingrédient, il ne sera plus reconnu !

    Essayons ! Lorsque j’ajoute un « t » à la fin de « lait », le « t » n’est plus vert, et lorsque je supprime un caractère de « lait », l’attribut ingredient est supprimé du mot entier en une seule fois. Sur la base de ma AttributedTextFormattingDefinition, l’attribut de couleur de premier plan continue de suivre parfaitement le comportement de mon attribut d’ingrédient personnalisé !

    Merci Jeremy, cette app est vraiment géniale ! Pas de problème ! - Maintenant, à propos des croissants que tu m’as promis... - Ne t’inquiète pas, ils sont presque prêts. Mais si tu surveillais le four, j’ai un peu peur que Luca ne nous les vole ! Ah, le fameux Luca dont j’ai entendu parler, l’amateur inconditionnel de widgets et de croissants. Je m’en occupe, chef ! Maintenant, avant que j’aille rejoindre Jeremy, laissez-moi vous donner quelques derniers conseils : Vous pouvez télécharger mon app à titre de référence pour en savoir plus sur l’utilisation du Transferable Wrapper de SwiftUI pour le glisser-déposer sans perte ou l’exportation vers RTFD, et la persistance d’AttributedString avec Swift Data. AttributedString fait partie du projet open source Foundation de Swift. Retrouvez son implémentation sur GitHub pour contribuer à son évolution ou entrez en contact avec la communauté sur les forums Swift ! Avec le nouveau TextEditor, il n’a jamais été aussi facile d’ajouter la prise en charge de l’entrée Genmoji dans votre app, alors pensez à le faire maintenant ! J’ai hâte de voir comment vous utiliserez cette API pour améliorer l’édition de texte dans vos apps. Un rien suffirait à le faire éclater !

    Mmm, tellement délicieux ! Et surtout RICHE !

    • 1:15 - TextEditor and String

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: String
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 1:45 - TextEditor and AttributedString

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
      
          var body: some View {
              TextEditor(text: $text)
          }
      }
    • 4:43 - AttributedString Basics

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get "
      )
      
      var cooking = AttributedString("cooking")
      cooking.foregroundColor = .orange
      text += cooking
      
      text += AttributedString("?")
      
      text.font = .largeTitle
    • 5:36 - Build custom controls: Basics (initial attempt)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection.indices(in: text)] // build error
      
              return IngredientSuggestion(
                  suggestedName: AttributedString())
          }
      }
    • 8:53 - Slicing AttributedString with a Range

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard let cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      text[cookingRange].foregroundColor = .orange
    • 10:50 - Slicing AttributedString with a RangeSet

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      let uppercaseRanges = text.characters
        .indices(where: \.isUppercase)
      
      text[uppercaseRanges].foregroundColor = .blue
    • 11:40 - Build custom controls: Basics (fixed)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name))
          }
      }
    • 12:32 - Build custom controls: Recipe attribute

      import SwiftUI
      
      struct IngredientAttribute: CodableAttributedStringKey {
          typealias Value = Ingredient.ID
      
          static let name = "SampleRecipeEditor.IngredientAttribute"
      }
      
      extension AttributeScopes {
          /// An attribute scope for custom attributes defined by this app.
          struct CustomAttributes: AttributeScope {
              /// An attribute for marking text as a reference to an recipe's ingredient.
              let ingredient: IngredientAttribute
          }
      }
      
      extension AttributeDynamicLookup {
          /// The subscript for pulling custom attributes into the dynamic attribute lookup.
          ///
          /// This makes them available throughout the code using the name they have in the
          /// `AttributeScopes.CustomAttributes` scope.
          subscript<T: AttributedStringKey>(
              dynamicMember keyPath: KeyPath<AttributeScopes.CustomAttributes, T>
          ) -> T {
              self[T.self]
          }
      }
    • 12:56 - Build custom controls: Modifying text (initial attempt)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = text.characters.ranges(of: name.characters)
      
                      for range in ranges {
                          // modifying `text` without updating `selection` is invalid and resets the cursor 
                          text[range].ingredient = ingredientId
                      }
                  })
          }
      }
    • 17:40 - AttributedString Character View

      text.characters[index] // "👋🏻"
    • 17:44 - AttributedString Unicode Scalar View

      text.unicodeScalars[index] // "👋"
    • 17:49 - AttributedString Runs View

      text.runs[index] // "Hello 👋🏻! ..."
    • 18:13 - AttributedString UTF-8 View

      text.utf8[index] // "240"
    • 18:17 - AttributedString UTF-16 View

      text.utf16[index] // "55357"
    • 18:59 - Updating Indices during AttributedString Mutations

      var text = AttributedString(
        "Hello 👋🏻! Who's ready to get cooking?"
      )
      
      guard var cookingRange = text.range(of: "cooking") else {
        fatalError("Unable to find range of cooking")
      }
      
      let originalRange = cookingRange
      text.transform(updating: &cookingRange) { text in
        text[originalRange].foregroundColor = .orange
        
        let insertionPoint = text
          .index(text.startIndex, offsetByCharacters: 6)
        
        text.characters
          .insert(contentsOf: "chef ", at: insertionPoint)
      }
      
      print(text[cookingRange])
    • 20:22 - Build custom controls: Modifying text (fixed)

      import SwiftUI
      
      struct RecipeEditor: View {
          @Binding var text: AttributedString
          @State private var selection = AttributedTextSelection()
      
          var body: some View {
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
          }
      
          private var newIngredientSuggestion: IngredientSuggestion {
              let name = text[selection]
      
              return IngredientSuggestion(
                  suggestedName: AttributedString(name),
                  onApply: { ingredientId in
                      let ranges = RangeSet(text.characters.ranges(of: name.characters))
      
                      text.transform(updating: &selection) { text in
                          text[ranges].ingredient = ingredientId
                      }
                  })
          }
      }
    • 22:03 - Define your text format: RecipeFormattingDefinition Scope

      struct RecipeFormattingDefinition: AttributedTextFormattingDefinition {
          struct Scope: AttributeScope {
              let foregroundColor: AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
              let adaptiveImageGlyph: AttributeScopes.SwiftUIAttributes.AdaptiveImageGlyphAttribute
              let ingredient: IngredientAttribute
          }
      
          var body: some AttributedTextFormattingDefinition<Scope> {
      
          }
      }
      
      // pass the custom formatting definition to the TextEditor in the updated `RecipeEditor.body`:
      
              TextEditor(text: $text, selection: $selection)
                  .preference(key: NewIngredientPreferenceKey.self, value: newIngredientSuggestion)
                  .attributedTextFormattingDefinition(RecipeFormattingDefinition())
    • 23:50 - Define your text format: AttributedTextValueConstraints

      struct IngredientsAreGreen: AttributedTextValueConstraint {
          typealias Scope = RecipeFormattingDefinition.Scope
          typealias AttributeKey = AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute
      
          func constrain(_ container: inout Attributes) {
              if container.ingredient != nil {
                  container.foregroundColor = .green
              } else {
                  container.foregroundColor = nil
              }
          }
      }
      
      // list the value constraint in the recipe formatting definition's body:
          var body: some AttributedTextFormattingDefinition<Scope> {
              IngredientsAreGreen()
          }
    • 29:28 - AttributedStringKey Constraint: Inherited by Added Text

      static let inheritedByAddedText = false
    • 30:12 - AttributedStringKey Constraint: Invalidation Conditions

      static let invalidationConditions:
        Set<AttributedString.AttributeInvalidationCondition>? =
        [.textChanged]
    • 31:25 - AttributedStringKey Constraint: Run Boundaries

      static let runBoundaries:
        AttributedString.AttributeRunBoundaries? =
        .paragraph
    • 32:46 - Define your text format: AttributedStringKey Constraints

      struct IngredientAttribute: CodableAttributedStringKey {
          typealias Value = Ingredient.ID
      
          static let name = "SampleRecipeEditor.IngredientAttribute"
      
          static let inheritedByAddedText: Bool = false
      
          static let invalidationConditions: Set<AttributedString.AttributeInvalidationCondition>? = [.textChanged]
      }
    • 0:00 - Introduction
    • Cette section présente l’objectif de la séance : montrer comment créer des expériences d’édition de texte enrichi dans SwiftUI en utilisant AttributedString. Le présentateur, Max, abordera trois grands axes : améliorer TextEditor pour prendre en charge le texte enrichi, créer des contrôles personnalisés pour améliorer l’éditeur, et définir une mise en forme pour un style cohérent.

    • 1:15 - TextEditor et AttributedString
    • Cette section se concentre sur la mise à niveau d’un TextEditor SwiftUI pour qu’il prenne en charge le texte enrichi, en remplaçant le type de données sous-jacent String par AttributedString. Cela active immédiatement la prise en charge des contrôles d’UI système pour les options de mise en forme comme le gras, l’italique, la taille de police, les couleurs et les Genmoji. TextEditor prend en charge un large éventail d’attributs, y compris la mise en forme des paragraphes. La section fournit également un bref rappel sur les bases d’AttributedString, notamment sa structure (caractères et plages d’attributs), sa nature de type valeur, son encodage en UTF-8, ainsi que sa conformité aux protocoles Swift courants. Elle met en avant l’utilisation d’attributs prédéfinis ainsi que la possibilité de définir des attributs personnalisés.

    • 5:36 - Créez des contrôles personnalisés
    • Cette section explique comment créer des contrôles personnalisés pour un TextEditor qui interagissent avec le reste de l’application. Elle montre comment ajouter un bouton qui permet à l’utilisateur de marquer le texte sélectionné comme étant un ingrédient. Elle aborde l’utilisation des preference keys pour permettre la communication entre l’éditeur et les autres éléments de l’interface. Cette section explore également la complexité des sélections de texte dans AttributedString, en expliquant pourquoi le type AttributedTextSelection utilise un RangeSet plutôt qu’un simple Range afin de gérer le texte bidirectionnel et les sélections discontinues. Elle met également en avant l’utilisation d’une API de sous-script permettant de découper directement un AttributedString à partir d’une sélection. Enfin, elle montre comment utiliser une fermeture pour modifier le texte de l’éditeur, en annotant les occurrences du texte sélectionné avec un attribut personnalisé Ingredient. Elle aborde également le problème de la sélection réinitialisée après la modification de l’AttributedString, et introduit la notion d’indices dans une AttributedString, en soulignant l’importance de les mettre à jour après mutation à l’aide de la fonction transform.

    • 22:02 - Définissez votre format de texte
    • Cette section se concentre sur l’utilisation du protocole de définition de formatage de texte attribué pour contrôler les options de mise en forme disponibles dans le TextEditor. Elle explique comment créer une définition de formatage personnalisée qui spécifie les AttributedStringKeys auxquels l’éditeur doit réagir, afin de restreindre les options de mise en forme disponibles. Elle présente également AttributedTextValueConstraint, qui permet d’appliquer des règles de formatage spécifiques, comme s’assurer que les ingrédients sont toujours mis en surbrillance en vert. La section explique en outre comment restreindre les valeurs d’attributs en utilisant le protocole AttributedStringKey. Elle aborde des propriétés telles que inheritedByAddedText et invalidationConditions, qui permettent de contrôler la manière dont les attributs sont hérités ou invalidés lors des modifications du texte. Enfin, elle traite de la propriété runBoundaries, qui permet d’imposer des valeurs cohérentes sur des portions de texte, comme les paragraphes.

    • 34:08 - Étapes suivantes
    • Cette section fournit des conseils de dernière minute ainsi que des ressources utiles. Elle mentionne un projet d’exemple qui démontre le glisser-déposer sans perte, l’exportation au format RTFD, et la persistance d’AttributedString avec Swift Data. Elle souligne qu’AttributedString fait partie du projet open source Foundation de Swift et encourage les contributions de la communauté. La section encourage également les développeurs à intégrer la prise en charge des Genmoji dans leurs apps en utilisant le nouveau TextEditor.

Developer Footer

  • Vidéos
  • WWDC25
  • Code-along : Préparation d’une expérience de texte enrichie dans SwiftUI avec AttributedString
  • Open Menu Close Menu
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    Open Menu Close Menu
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • Icon Composer
    • SF Symbols
    Open Menu Close Menu
    • Accessibility
    • Accessories
    • App Store
    • Audio & Video
    • Augmented Reality
    • Business
    • Design
    • Distribution
    • Education
    • Fonts
    • Games
    • Health & Fitness
    • In-App Purchase
    • Localization
    • Maps & Location
    • Machine Learning & AI
    • Open Source
    • Security
    • Safari & Web
    Open Menu Close Menu
    • Documentation
    • Sample Code
    • Tutorials
    • Downloads
    • Forums
    • Videos
    Open Menu Close Menu
    • Support Articles
    • Contact Us
    • Bug Reporting
    • System Status
    Open Menu Close Menu
    • Apple Developer
    • App Store Connect
    • Certificates, IDs, & Profiles
    • Feedback Assistant
    Open Menu Close Menu
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program
    • News Partner Program
    • Video Partner Program
    • Security Bounty Program
    • Security Research Device Program
    Open Menu Close Menu
    • Meet with Apple
    • Apple Developer Centers
    • App Store Awards
    • Apple Design Awards
    • Apple Developer Academies
    • WWDC
    Get the Apple Developer app.
    Copyright © 2025 Apple Inc. All rights reserved.
    Terms of Use Privacy Policy Agreements and Guidelines