Introduction : SwiftUI accueille enfin l'édition de texte riche
Si vous avez déjà essayé de construire un éditeur de texte riche dans SwiftUI, vous connaissez la douleur. On arrivait à 80 % du résultat avec TextEditor, puis c'était le mur. Du gras ? Des couleurs ? Des styles en ligne ? Oubliez. La seule issue : redescendre dans UIKit, encapsuler un UITextView avec UIViewRepresentable, jongler avec des delegates, et prier pour que tout reste synchronisé avec l'état SwiftUI. Honnêtement, c'était frustrant.
Avec iOS 26, Apple a changé la donne. TextEditor supporte désormais nativement AttributedString, et trois nouvelles APIs — AttributedTextSelection, FontResolutionContext et transformAttributes(in:) — permettent de construire un éditeur de texte riche complet, performant et 100 % natif SwiftUI.
Pas de UIKit. Pas de wrappers. Pas de hacks.
Dans ce guide, on va construire cet éditeur étape par étape : du TextEditor minimal jusqu'à un éditeur complet avec barre d'outils de formatage, synchronisation d'état, couleurs personnalisées et persistance des données. Chaque concept est accompagné de code fonctionnel que vous pouvez copier directement dans votre projet.
Prérequis
- Xcode 26 ou supérieur
- iOS 26+, macOS 26+, iPadOS 26+ ou visionOS 26+
- Des bases en SwiftUI (vues,
@State,@Environment)
Si vous devez supporter des versions antérieures, il faudra toujours passer par UIKit — on en reparle en fin d'article. Mais pour tout nouveau projet ciblant iOS 26, l'approche native SwiftUI est clairement la voie à suivre.
Étape 1 : un TextEditor avec AttributedString en trois lignes
La première surprise (et quelle surprise), c'est la simplicité. Pour créer un éditeur de texte riche minimal, il suffit de changer le type de la variable liée au TextEditor de String à AttributedString :
import SwiftUI
struct SimpleRichEditor: View {
@State private var text = AttributedString("Tapez ici...")
var body: some View {
TextEditor(text: $text)
.padding()
}
}
C'est tout. Sérieusement. Avec ce simple changement, le TextEditor supporte automatiquement les raccourcis clavier de mise en forme (⌘B pour le gras, ⌘I pour l'italique) et les contrôles du menu contextuel. SwiftUI gère tout en interne.
Bon, dans la vraie vie, vous voudrez probablement aller plus loin : offrir des boutons de formatage visibles, synchroniser l'état de la barre d'outils avec la sélection, appliquer des styles personnalisés… C'est là que les nouvelles APIs entrent en jeu.
Étape 2 : comprendre les trois APIs clés
Avant de plonger dans le code complet, prenons un moment pour comprendre les trois piliers de l'édition de texte riche dans SwiftUI sous iOS 26.
AttributedString : le modèle de données
AttributedString est une chaîne Swift native de type valeur capable de contenir des informations de formatage riche : police, couleur, soulignement, arrière-plan, liens, et bien plus. C'est l'équivalent moderne de NSAttributedString, mais en nettement plus agréable à utiliser.
Contrairement à son prédécesseur, AttributedString est :
- Type valeur — fini les mutations inattendues par référence
- Type-safe — les attributs sont vérifiés à la compilation
- Compatible Swift Concurrency —
Sendablepar défaut
var texte = AttributedString("Bonjour le monde")
// Appliquer du gras à toute la chaîne
texte.font = .body.bold()
// Appliquer une couleur à un sous-ensemble
if let range = texte.range(of: "monde") {
texte[range].foregroundColor = .blue
texte[range].underlineStyle = .single
}
AttributedTextSelection : suivre la sélection utilisateur
AttributedTextSelection représente la partie du texte que l'utilisateur a sélectionnée, ou la position actuelle du curseur. Au lieu de manipuler des NSRange qui peuvent se désynchroniser à la moindre modification concurrente, SwiftUI encapsule toute cette logique proprement.
Concrètement, AttributedTextSelection sert à deux choses :
- Appliquer du formatage uniquement au texte sélectionné
- Lire les attributs actifs à la position du curseur pour synchroniser votre interface
@State private var selection = AttributedTextSelection()
// Liaison avec le TextEditor
TextEditor(text: $text, selection: $selection)
FontResolutionContext : résoudre les polices correctement
Celle-ci est un peu moins intuitive. FontResolutionContext est une valeur d'environnement qui sait comment les polices résolvent des traits comme le gras ou l'italique. Sans elle, basculer un style pourrait donner des résultats incohérents — surtout avec des polices personnalisées, le Dynamic Type, ou les paramètres d'accessibilité.
Pourquoi une valeur d'environnement ? Parce que les polices dans SwiftUI sont des ressources adaptatives, pas des valeurs absolues. En résolvant les polices dans l'environnement, le formatage correspond toujours à ce que l'utilisateur voit réellement à l'écran. C'est malin.
@Environment(\.fontResolutionContext) var fontResolutionContext
// Vérifier si la police actuelle est en gras
let font = container.font ?? .default
let resolved = font.resolve(in: fontResolutionContext)
let estGras = resolved.isBold
Étape 3 : construire une barre d'outils de formatage
Allez, passons aux choses sérieuses. Voici comment construire un éditeur de texte riche avec des boutons Gras, Italique, Souligné et Barré dans la barre d'outils :
import SwiftUI
struct RichTextEditorView: View {
@Environment(\.fontResolutionContext) var fontResolutionContext
@State private var text = AttributedString()
@State private var selection = AttributedTextSelection()
var body: some View {
NavigationStack {
TextEditor(text: $text, selection: $selection)
.padding()
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
Button {
toggleBold()
} label: {
Image(systemName: "bold")
.symbolVariant(isBold ? .fill : .none)
}
Button {
toggleItalic()
} label: {
Image(systemName: "italic")
.symbolVariant(isItalic ? .fill : .none)
}
Button {
toggleUnderline()
} label: {
Image(systemName: "underline")
.symbolVariant(isUnderlined ? .fill : .none)
}
Button {
toggleStrikethrough()
} label: {
Image(systemName: "strikethrough")
.symbolVariant(isStrikethrough ? .fill : .none)
}
Spacer()
}
}
}
}
}
Remarquez l'utilisation de symbolVariant(.fill) pour donner un retour visuel quand un style est actif. C'est un petit détail, mais ça fait une vraie différence côté UX — l'utilisateur voit immédiatement quel formatage est appliqué.
Étape 4 : implémenter les fonctions de formatage
Voici l'implémentation complète des fonctions de basculement pour chaque style. C'est ici que la magie opère :
extension RichTextEditorView {
// MARK: - Basculement des styles
private func toggleBold() {
text.transformAttributes(in: &selection) { container in
let currentFont = container.font ?? .default
let resolved = currentFont.resolve(in: fontResolutionContext)
container.font = currentFont.bold(!resolved.isBold)
}
}
private func toggleItalic() {
text.transformAttributes(in: &selection) { container in
let currentFont = container.font ?? .default
let resolved = currentFont.resolve(in: fontResolutionContext)
container.font = currentFont.italic(!resolved.isItalic)
}
}
private func toggleUnderline() {
text.transformAttributes(in: &selection) { container in
container.underlineStyle = (container.underlineStyle == nil)
? .single
: nil
}
}
private func toggleStrikethrough() {
text.transformAttributes(in: &selection) { container in
container.strikethroughStyle = (container.strikethroughStyle == nil)
? .single
: nil
}
}
}
Le point important ici, c'est transformAttributes(in:). Cette méthode prend la sélection en paramètre inout (d'où le &) parce que SwiftUI peut avoir besoin de l'ajuster pendant que les attributs sont modifiés — par exemple, si des runs adjacents fusionnent après l'édition. Résultat : la sélection reste toujours valide.
Pour le gras et l'italique, on passe par FontResolutionContext afin de résoudre la police actuelle avant de la basculer. Pour le souligné et le barré, c'est plus direct : on bascule simplement l'attribut entre nil et .single.
Étape 5 : synchroniser l'état de la barre d'outils
Un éditeur de texte riche digne de ce nom met à jour ses boutons quand l'utilisateur déplace le curseur ou change la sélection. Et franchement, c'est là que SwiftUI brille. Grâce à typingAttributes(in:), cette synchronisation devient presque triviale :
extension RichTextEditorView {
// MARK: - État des styles
private var isBold: Bool {
let attrs = selection.typingAttributes(in: text)
guard let font = attrs.font else { return false }
return font.resolve(in: fontResolutionContext).isBold
}
private var isItalic: Bool {
let attrs = selection.typingAttributes(in: text)
guard let font = attrs.font else { return false }
return font.resolve(in: fontResolutionContext).isItalic
}
private var isUnderlined: Bool {
let attrs = selection.typingAttributes(in: text)
return attrs.underlineStyle != nil
}
private var isStrikethrough: Bool {
let attrs = selection.typingAttributes(in: text)
return attrs.strikethroughStyle != nil
}
}
typingAttributes(in:) retourne les attributs qui seront appliqués au prochain caractère tapé à la position actuelle du curseur. SwiftUI met automatiquement ces valeurs à jour quand la sélection change, ce qui veut dire que vos propriétés calculées se rafraîchissent toutes seules. Pas de NotificationCenter, pas de delegates. Ça fait du bien.
Étape 6 : ajouter des couleurs personnalisées
Le formatage typographique, c'est bien. Mais un vrai éditeur de texte riche, ça gère aussi les couleurs. Voici comment intégrer un sélecteur de couleur :
struct ColorFormattingView: View {
@Environment(\.fontResolutionContext) var fontResolutionContext
@State private var text = AttributedString("Sélectionnez du texte et choisissez une couleur")
@State private var selection = AttributedTextSelection()
@State private var selectedColor: Color = .primary
var body: some View {
VStack {
TextEditor(text: $text, selection: $selection)
.padding()
HStack(spacing: 12) {
ForEach([Color.red, .blue, .green, .orange, .purple], id: \.self) { color in
Circle()
.fill(color)
.frame(width: 32, height: 32)
.overlay(
Circle()
.stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0)
)
.onTapGesture {
selectedColor = color
applyColor(color)
}
}
ColorPicker("", selection: $selectedColor)
.labelsHidden()
.onChange(of: selectedColor) { _, newColor in
applyColor(newColor)
}
}
.padding()
}
}
private func applyColor(_ color: Color) {
text.transformAttributes(in: &selection) { container in
container.foregroundColor = color
}
}
}
Même pattern transformAttributes(in:) que pour le gras ou l'italique — sauf qu'on modifie foregroundColor au lieu de la police. Le ColorPicker natif SwiftUI offre en plus un choix illimité de couleurs pour les utilisateurs qui veulent quelque chose de précis.
Étape 7 : gérer la taille de police
Changer dynamiquement la taille de police du texte sélectionné, c'est un autre besoin qu'on rencontre souvent. Voici une approche combinant un Stepper avec transformAttributes :
struct FontSizeControlView: View {
@Environment(\.fontResolutionContext) var fontResolutionContext
@State private var text = AttributedString("Ajustez la taille de ce texte")
@State private var selection = AttributedTextSelection()
@State private var fontSize: Double = 17
var body: some View {
VStack {
TextEditor(text: $text, selection: $selection)
.padding()
HStack {
Text("Taille : \(Int(fontSize)) pt")
Stepper("", value: $fontSize, in: 10...72, step: 1)
.labelsHidden()
.onChange(of: fontSize) { _, newSize in
applyFontSize(newSize)
}
}
.padding()
}
}
private func applyFontSize(_ size: Double) {
text.transformAttributes(in: &selection) { container in
container.font = .system(size: CGFloat(size))
}
}
}
Attention, point subtil : quand on change la taille de police ici, on remplace la police existante par une police système de la taille souhaitée. Si vous voulez préserver les traits existants (gras, italique), il faudra d'abord résoudre la police actuelle, puis en recréer une avec la nouvelle taille tout en conservant les traits. C'est un piège classique.
Étape 8 : le menu de formatage contextuel
iOS 26 apporte aussi un menu de formatage contextuel qui apparaît automatiquement quand l'utilisateur sélectionne du texte dans un TextEditor. Ce menu propose des options natives pour modifier les attributs directement — police, taille, couleur — et tout ça sans écrire une seule ligne de code supplémentaire.
Ce menu est activé par défaut dès que vous utilisez AttributedString avec TextEditor. Il cohabite très bien avec votre barre d'outils personnalisée : l'utilisateur choisit ce qu'il préfère.
Pour personnaliser les options disponibles dans ce menu, il existe le protocole AttributedTextFormattingDefinition. Il permet de définir quels AttributedStringKeys votre éditeur prend en charge et quelles valeurs ils peuvent avoir :
struct MonFormatage: AttributedTextFormattingDefinition {
enum Priority: String, AttributedTextValueConstraint {
case haute = "Haute"
case moyenne = "Moyenne"
case basse = "Basse"
static var attributedStringKey: AttributeScopes.FoundationAttributes.CustomKey {
// Définir la clé personnalisée
}
}
}
C'est particulièrement utile quand vous construisez un éditeur spécialisé — un outil de prise de notes avec des niveaux de priorité, par exemple, ou un éditeur de code avec des catégories syntaxiques.
Étape 9 : persister le texte riche
Un éditeur de texte riche sans sauvegarde, ça ne sert pas à grand-chose. Voyons deux approches pour persister et recharger le contenu formaté.
Approche 1 : sérialisation JSON avec Codable
AttributedString conforme à Codable, ce qui rend la sérialisation quasi triviale :
// Sauvegarder
func save(_ text: AttributedString, to url: URL) throws {
let data = try JSONEncoder().encode(text)
try data.write(to: url)
}
// Charger
func load(from url: URL) throws -> AttributedString {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(AttributedString.self, from: data)
}
Approche 2 : stockage avec SwiftData
Si vous utilisez SwiftData, c'est un poil plus compliqué. AttributedString n'est pas directement supporté comme type de propriété dans un modèle SwiftData. La solution recommandée : stocker les données encodées en Data.
import SwiftData
@Model
class Note {
var titre: String
var contenuData: Data
var dateCreation: Date
init(titre: String, contenu: AttributedString) {
self.titre = titre
self.contenuData = (try? JSONEncoder().encode(contenu)) ?? Data()
self.dateCreation = .now
}
var contenu: AttributedString {
get {
(try? JSONDecoder().decode(AttributedString.self, from: contenuData))
?? AttributedString()
}
set {
contenuData = (try? JSONEncoder().encode(newValue)) ?? Data()
}
}
}
L'astuce est d'encapsuler la logique d'encodage/décodage dans le modèle lui-même via la propriété calculée contenu. Le reste de votre code manipule un AttributedString normalement, sans se soucier de la sérialisation en arrière-plan.
Étape 10 : assembler le tout — l'éditeur complet
On y est. Voici le code complet d'un éditeur de texte riche prêt à l'emploi, qui combine tout ce qu'on a vu :
import SwiftUI
struct CompleteRichTextEditor: View {
@Environment(\.fontResolutionContext) var fontResolutionContext
@State private var text = AttributedString()
@State private var selection = AttributedTextSelection()
var body: some View {
NavigationStack {
TextEditor(text: $text, selection: $selection)
.padding()
.navigationTitle("Mon éditeur")
.toolbarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
// Formatage typographique
Group {
formatButton("bold", isActive: isBold) { toggleBold() }
formatButton("italic", isActive: isItalic) { toggleItalic() }
formatButton("underline", isActive: isUnderlined) { toggleUnderline() }
formatButton("strikethrough", isActive: isStrikethrough) { toggleStrikethrough() }
}
Spacer()
// Couleurs rapides
Menu {
ForEach(
[("Rouge", Color.red), ("Bleu", .blue),
("Vert", .green), ("Orange", .orange)],
id: \.0
) { nom, couleur in
Button(nom) { applyColor(couleur) }
}
Button("Par défaut") { applyColor(.primary) }
} label: {
Image(systemName: "paintpalette")
}
}
}
}
}
// MARK: - Composants UI
@ViewBuilder
private func formatButton(
_ symbol: String,
isActive: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.symbolVariant(isActive ? .fill : .none)
}
}
// MARK: - Fonctions de formatage
private func toggleBold() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .default
let resolved = font.resolve(in: fontResolutionContext)
container.font = font.bold(!resolved.isBold)
}
}
private func toggleItalic() {
text.transformAttributes(in: &selection) { container in
let font = container.font ?? .default
let resolved = font.resolve(in: fontResolutionContext)
container.font = font.italic(!resolved.isItalic)
}
}
private func toggleUnderline() {
text.transformAttributes(in: &selection) { container in
container.underlineStyle = container.underlineStyle == nil ? .single : nil
}
}
private func toggleStrikethrough() {
text.transformAttributes(in: &selection) { container in
container.strikethroughStyle = container.strikethroughStyle == nil ? .single : nil
}
}
private func applyColor(_ color: Color) {
text.transformAttributes(in: &selection) { container in
container.foregroundColor = color
}
}
// MARK: - État des styles
private var isBold: Bool {
guard let font = selection.typingAttributes(in: text).font else { return false }
return font.resolve(in: fontResolutionContext).isBold
}
private var isItalic: Bool {
guard let font = selection.typingAttributes(in: text).font else { return false }
return font.resolve(in: fontResolutionContext).isItalic
}
private var isUnderlined: Bool {
selection.typingAttributes(in: text).underlineStyle != nil
}
private var isStrikethrough: Bool {
selection.typingAttributes(in: text).strikethroughStyle != nil
}
}
Moins de 100 lignes, et on a déjà : gras, italique, souligné, barré, couleurs personnalisées, synchronisation automatique de la barre d'outils, et le menu contextuel natif d'iOS 26. Il y a quelques mois à peine, ce résultat aurait demandé des centaines de lignes de code UIKit (et probablement quelques cheveux en moins).
Bonnes pratiques et pièges à éviter
Ordre de déclaration des propriétés @State
Déclarez toujours text avant selection. SwiftUI résout les propriétés @State dans l'ordre de déclaration, et la sélection dépend du texte pour être valide. Inversez-les, et vous risquez des comportements bizarres difficiles à déboguer.
Performances avec de longs documents
AttributedString est un type valeur. Pour des documents très longs (plusieurs milliers de mots), chaque modification crée une copie. Si vous constatez des ralentissements, envisagez de découper le contenu en sections avec des éditeurs séparés, ou d'utiliser un mécanisme de debounce sur les transformations.
Compatibilité avec les anciennes versions
Si votre app doit encore tourner sur iOS 25, une vérification de disponibilité s'impose :
if #available(iOS 26, *) {
TextEditor(text: $attributedText, selection: $selection)
} else {
// Fallback UIKit avec UIViewRepresentable
LegacyRichTextEditor(text: $plainText)
}
Accessibilité
Bonne nouvelle : le TextEditor natif gère automatiquement VoiceOver, le Dynamic Type et les tailles d'accessibilité. Grâce à FontResolutionContext, vos transformations de police respectent les préférences d'accessibilité de l'utilisateur. C'est un avantage considérable par rapport aux solutions UIKit personnalisées où l'accessibilité devait souvent être gérée manuellement.
FAQ
Peut-on utiliser TextEditor avec AttributedString sur iOS 25 ou antérieur ?
Non. Le support de AttributedString dans TextEditor est exclusif à iOS 26+. Sur les versions précédentes, TextEditor n'accepte qu'un String. Pour le texte riche sur d'anciennes versions, il faut encapsuler un UITextView avec UIViewRepresentable.
Quelle est la différence entre AttributedString et NSAttributedString ?
AttributedString est le successeur Swift-natif de NSAttributedString. Les différences clés : c'est un type valeur (pas une classe), les attributs sont vérifiés à la compilation grâce au typage fort, et il conforme à Codable et Sendable. La conversion entre les deux reste simple : NSAttributedString(attributedString) et AttributedString(nsAttributedString).
Comment sauvegarder un AttributedString dans SwiftData ?
AttributedString n'est pas directement supporté comme type SwiftData. La solution : encoder en Data via JSONEncoder, stocker cette Data dans votre modèle, et exposer une propriété calculée qui gère la conversion de façon transparente (on a vu ça à l'étape 9).
Le menu de formatage contextuel est-il personnalisable ?
Oui. Le protocole AttributedTextFormattingDefinition d'iOS 26 permet de définir quels attributs votre éditeur expose dans le menu contextuel. Vous pouvez restreindre les options ou en ajouter de nouvelles, adaptées à votre cas d'usage.
Comment gérer le Markdown dans un TextEditor riche ?
AttributedString supporte nativement l'initialisation depuis du Markdown : AttributedString(markdown: "**Gras** et *italique*"). Le texte est automatiquement formaté. En revanche, l'édition en temps réel avec syntaxe Markdown visible (comme dans un éditeur Markdown classique) nécessite une implémentation personnalisée par-dessus le TextEditor.