-
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
Vidéos connexes
WWDC25
WWDC22
WWDC21
-
Rechercher dans cette vidéo…
-
-
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.