Pour que le curseur reste en place pendant la modification d’une liste SwiftUI, attribuez à chaque élément un identifiant immuable, liez directement son texte et préservez la position de l’éditeur dans la hiérarchie des vues. À partir de macOS 15, utilisez une liaison de sélection si votre application doit également contrôler le point d’insertion.

Ce tutoriel crée une petite application Mac permettant de modifier des libellés fictifs tels que Blue tile. L’objectif est de continuer à saisir du texte dans le même élément pendant que son texte et la liste environnante sont mis à jour. Il explique également quand la restauration de la sélection nécessite un état supplémentaire ou un éditeur AppKit.

Pourquoi le curseur se déplace-t-il quand un élément de la liste change ?

Commencez par vérifier si l’identité de l’élément change en même temps que son texte. SwiftUI utilise l’identité pour relier les valeurs successives d’une vue, et l’état persistant de la vue est associé à cette identité. Réévaluer le body d’une vue est normal ; remplacer son identité met fin à sa durée de vie. Apple explique cette relation dans Démystifier SwiftUI.

Pour une collection modifiable, évitez d’identifier une ligne par son libellé modifiable. Avec id: \.self, l’identité d’une chaîne change lorsque la chaîne change. Le même problème peut se produire avec un modèle dont le hachage inclut le libellé. Identifier les éléments mobiles par leur position dans le tableau associe également l’identité à un emplacement plutôt qu’à l’élément logique.

Utilisez plutôt un identifiant stocké qui reste le même après les modifications. La documentation de ForEach d’Apple prend en charge les collections d’éléments identifiables ou une clé de chemin d’identifiant explicite. Pour cet éditeur, le choix pratique est un UUID stocké appartenant à l’élément.

Supprimez également les modificateurs tels que .id(item.text) ou .id(UUID()) de l’éditeur et de ses vues parentes. Apple précise dans la documentation que modifier la valeur transmise à id(_:) réinitialise l’identité de la vue, y compris son état. Un identifiant de ligne stable ne peut pas compenser une vue parente dont l’identité change à chaque modification.

Distinguer l’identité, le focus et la sélection

Ces trois états répondent à des questions différentes. L’identité détermine quelle ligne logique persiste au fil des mises à jour. Le focus détermine quel contrôle reçoit la saisie au clavier. La sélection détermine le point d’insertion ou le texte sélectionné dans ce contrôle.

FocusState permet à une application d’observer et de modifier le focus. Attribuez une valeur de focus distincte à chaque champ, par exemple l’identifiant de son élément. Lier toutes les lignes à la même valeur booléenne ne permet pas de déterminer quel élément doit recevoir une demande de focus par programmation.

TextSelection représente un point d’insertion, une plage sélectionnée ou plusieurs sélections sur macOS. L’initialiseur de TextField avec une liaison de sélection est disponible à partir de macOS 15.

L’implémentation ci-dessous respecte ces rôles documentés : le modèle possède l’identité et le texte, la liste possède l’identifiant de l’élément actif, et chaque ligne vivante possède sa sélection. Cette conception vise à permettre une modification sans interruption tant que la ligne reste présente ; elle ne garantit pas la restauration de tous les détails d’édition après la suppression et la recréation d’une ligne.

Créer une liste avec des identifiants d’élément stables

Créez une application SwiftUI pour macOS dans Xcode et définissez sa version minimale de déploiement sur macOS 15 ou ultérieur. Utilisez un SDK qui inclut l’initialiseur avec liaison de sélection. Remplacez les déclarations générées de l’application et de la vue de contenu par cet exemple complet, en ne conservant qu’une seule déclaration @main dans la cible.

import Foundation
import SwiftUI

struct LabelItem: Identifiable {
    let id: UUID
    var text: String

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
    }
}

@main
struct CaretListApp: App {
    var body: some Scene {
        WindowGroup {
            LabelListView()
        }
    }
}

struct LabelListView: View {
    @State private var items = [
        LabelItem(text: "Blue tile"),
        LabelItem(text: "Round pebble"),
        LabelItem(text: "Paper square")
    ]
    @FocusState private var focusedID: UUID?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                Button("Add label") {
                    items.append(LabelItem(text: "New label"))
                }
                .keyboardShortcut("n", modifiers: [.command, .shift])

                Button("Reverse rows") {
                    items.reverse()
                }
                .keyboardShortcut("r", modifiers: [.command, .shift])
            }

            List {
                ForEach($items) { $item in
                    LabelRow(item: $item, focus: $focusedID)
                }
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 260)
    }
}

struct LabelRow: View {
    @Binding var item: LabelItem
    let focus: FocusState<UUID?>.Binding
    @State private var selection: TextSelection? = nil

    var body: some View {
        TextField("Label", text: $item.text, selection: $selection)
            .textFieldStyle(.roundedBorder)
            .focused(focus, equals: item.id)
    }
}

Le id stocké est créé lors de la création d’un élément. Modifier text ou inverser l’ordre du tableau existant préserve cet identifiant. Le ForEach basé sur une liaison fournit à chaque ligne la valeur de modèle modifiable, et le champ écrit directement dans le texte de cet élément.

Un TextField lié à une chaîne met sa liaison à jour en continu pendant la saisie. Cet exemple n’a donc pas besoin d’un gestionnaire de modifications supplémentaire pour copier chaque frappe dans un second tableau ou reconstruire la liste.

La ligne conserve un éditeur toujours placé au même endroit. Il n’y a ni modificateur d’identité dépendant du texte, ni réinitialisation automatique de la sélection, ni demande de focus à chaque mise à jour. La liaison de sélection permet au champ de communiquer la sélection courante sans que le code de l’application ne la remplace sans cesse.

L’exemple a été examiné à l’aide des déclarations d’API récupérées. Il n’a pas été compilé ni exécuté ici, car cet environnement ne dispose ni d’un compilateur Swift ni d’un environnement d’exécution macOS.

Préserver les identifiants lors de l’actualisation du modèle

Une actualisation doit conserver les identifiants des éléments qui correspondent toujours aux mêmes libellés. Créer un nouveau LabelItem(text:) pour chaque élément existant génère de nouveaux identifiants, même si tout le texte visible reste inchangé.

Lors de la reconstruction d’une collection à partir des enregistrements stockés par votre application, transmettez à la nouvelle valeur du modèle l’identifiant existant de chaque enregistrement. Ne générez un nouvel identifiant que pour un élément réellement nouveau. Si les enregistrements n’ont pas d’identifiant persistant, ajoutez-en un à leur création au lieu de le dériver du texte modifiable.

Évitez de trier par libellé après chaque frappe, sauf si le déplacement immédiat est intentionnel. Une identité stable indique à SwiftUI quel élément a changé de place, mais une ligne qui change de position pendant la saisie modifie tout de même l’expérience d’édition. Une action de tri explicite rend la transition plus claire pour l’interface.

Diagnostiquer méthodiquement un saut de curseur persistant

L’aide à la décision suivante découle de la distinction documentée entre identité, focus et sélection. Elle aide à cerner le problème avant d’ajouter du code de restauration.

Comportement observé Premier point à vérifier Étape suivante appropriée
L’éditeur se réinitialise à chaque caractère Identifiants dérivés du texte ; valeurs de .id(...) modifiées Garder stables l’identifiant de l’élément et les identités des vues parentes
La saisie au clavier n’atteint plus le champ Changement de focus ou suppression de l’éditeur Examiner focusedID et les branches de vues conditionnelles
Le champ conserve le focus, mais le curseur se déplace Écriture de l’application dans le texte ou la sélection Supprimer les réécritures et affectations de sélection inconditionnelles
L’état d’édition reste associé à une position après le réordonnancement Identité basée sur l’index Identifier l’élément logique à l’aide de son identifiant stocké
La sélection disparaît après la suppression puis le réaffichage d’une ligne filtrée Durée de vie de l’état propre à la ligne Stocker les informations de restauration au-dessus de la ligne supprimable

Commencez par reproduire le problème sans tri, filtrage, formatage ni actualisation du modèle. Modifiez le milieu de Blue tile. Si cela fonctionne, réintroduisez les opérations une par une. Vous isolerez ainsi le changement associé au saut.

Examinez ensuite les changements de structure. Passer d’une branche conditionnelle à une autre qui contient un éditeur distinct peut remplacer l’identité de l’éditeur. L’explication d’Apple sur l’identité et la durée de vie indique qu’il vaut mieux conserver un éditeur unique lorsque seule son apparence doit changer.

Enfin, examinez les écritures dans la liaison de texte. Un gestionnaire qui coupe les espaces, met en majuscule ou remplace autrement la chaîne pendant la saisie introduit une seconde opération d’édition. Pour nettoyer les libellés, une stratégie pratique consiste à laisser le brouillon inchangé pendant la saisie et à appliquer la transformation à la validation.

Déterminer ce qui doit survivre à la suppression d’une ligne

L’état de sélection propre à la ligne convient à une ligne qui reste vivante. Il ne suffit pas à lui seul pour restaurer la sélection si l’interface supprime cette ligne. Les notes de version de macOS 15 d’Apple précisent également que ForEach peut récupérer l’état persistant associé aux vues enfants inutilisées.

Si le retour à un élément doit restaurer la position d’édition, conservez les informations de restauration dans un propriétaire situé au-dessus des lignes supprimables et indexez-les par identifiant d’élément. Associez-les à la version du texte qu’elles décrivent. Supprimez ces informations lorsque l’élément est définitivement supprimé.

La restauration du focus et celle de la sélection sont deux opérations distinctes. Si l’élément n’existe plus, ne redirigez pas son curseur mémorisé vers l’élément qui occupe désormais la même position dans le tableau.

Définir une stratégie de curseur explicite pour les changements de texte par programmation

Une identité stable ne peut pas déterminer où le curseur doit aller après le remplacement du texte par votre application. Cela nécessite une règle adaptée à l’action : préserver sa position logique, sélectionner le texte de remplacement ou déplacer le curseur à la fin.

Les initialiseurs de TextSelection de SwiftUI acceptent un point d’insertion ou des plages fondés sur String.Index. Lorsque votre application modifie la chaîne, construisez une nouvelle sélection à partir de la chaîne obtenue. Ne considérez pas une ancienne sélection comme un repère numérique permanent.

Les chaînes Swift utilisent String.Index, et leur nombre de caractères peut différer de leur longueur UTF-16, utilisée par NSString. Ces distinctions sont décrites dans Chaînes et caractères. Elles comptent lors de la conversion entre les sélections SwiftUI et les plages de texte Cocoa.

À titre d’exemple, supposons que le texte soit Blue tile et que le curseur se trouve juste après Blue. Insérer Soft au début doit avancer le curseur de cinq positions si l’objectif est de préserver sa position par rapport au texte d’origine. En laissant son décalage numérique inchangé, on le placerait dans une autre partie du libellé.

Cet exemple utilise du texte ASCII simple. Pour des chaînes générales, définissez l’unité de décalage et validez les limites obtenues. Un remplacement qui chevauche le texte sélectionné nécessite également un résultat défini ; borner simplement un décalage à la nouvelle longueur ne détermine pas la sélection souhaitée.

Quand utiliser AppKit ou TextKit ?

Pour les versions minimales de macOS antérieures, l’initialiseur SwiftUI avec liaison de sélection de macOS 15 n’est pas disponible. Vous pouvez tout de même corriger une identité instable dans un champ SwiftUI standard. Si l’application doit également contrôler explicitement la sélection, vous pouvez notamment intégrer un éditeur AppKit via NSViewRepresentable.

Apple décrit NSTextView comme une vue d’édition qui regroupe les fonctionnalités de TextKit. Il n’est pas nécessaire de créer un moteur de mise en page personnalisé simplement pour conserver un curseur. Choisissez une vue de texte dédiée lorsque vous avez besoin de contrôler directement son état d’édition.

Un wrapper doit suivre cette séquence de mise à jour :

  1. Créer l’éditeur AppKit dans makeNSView(context:) et connecter son délégué par l’intermédiaire d’un coordinateur.
  2. Transmettre les modifications de l’utilisateur au modèle lié et les changements de sélection à l’état associé à l’identifiant de l’élément.
  3. Dans updateNSView, comparer le texte du modèle au texte courant de l’éditeur avant toute affectation.
  4. En cas de véritable remplacement par programmation, calculer la sélection de remplacement à partir du texte obtenu et l’appliquer une fois.
  5. Empêcher le retour d’informations du délégué causé par cette mise à jour de l’application, et gérer la composition du texte marqué avant d’appliquer les transformations différées.

Il s’agit de règles d’implémentation pour le pont, et non d’un deuxième exemple de code complet. Apple fournit selectedRanges pour lire et écrire les sélections des vues de texte et textViewDidChangeSelection(_:) pour recevoir les changements de sélection. Préservez les plages multiples si l’éditeur les prend en charge.

Pour les mises à jour qui tiennent compte de la composition, hasMarkedText() indique si du texte marqué est présent. Une stratégie pratique consiste à différer les transformations non essentielles de l’application pendant la composition, puis à les appliquer lorsqu’elle prend fin.

Un NSTextField AppKit présente une autre particularité de cycle de vie : Apple précise que les contrôles simples partagent l’éditeur de champ de leur fenêtre. Associez la sélection mémorisée à l’élément et à la session d’édition active au lieu de considérer cet éditeur partagé comme un objet permanent propre à chaque ligne.

Respectez les limites d’intégration documentées dans le wrapper. SwiftUI contrôle la disposition de la vue représentée, et Apple déconseille de définir directement son cadre ou ses limites gérés. Évitez également de faire des suppositions sur l’implémentation interne des listes SwiftUI : les notes de version de macOS 15 indiquent que les listes autres que les listes hiérarchiques n’utilisent plus NSTableView.

Vérifier le résultat de l’édition dans votre propre fenêtre

Utilisez les libellés d’exemple pour vérifier chaque comportement séparément. Les étapes suivantes sont des vérifications à effectuer, et non des résultats de tests rapportés.

  1. Placez le curseur entre Blue et l’espace qui suit. Saisissez small. Vérifiez que la saisie continue au point d’insertion attendu.
  2. Sélectionnez tile et remplacez-le par square. Vérifiez que le remplacement ne concerne que le texte sélectionné.
  3. Pendant une édition, utilisez Ajouter un libellé avec Commande-Maj-N. Vérifiez séparément l’élément actif, le focus et le curseur.
  4. Utilisez Inverser l’ordre des lignes avec Commande-Maj-R. Vérifiez que la valeur modifiée reste associée à son identifiant d’élément d’origine, puis contrôlez le focus et la sélection.
  5. Dans votre interface réelle, testez la suppression et la réapparition des lignes, ainsi que le remplacement du texte par programmation, selon la stratégie de restauration choisie.

Cliquer sur un bouton peut modifier le focus. Distinguez donc une transition de focus provoquée par l’interaction d’une réinitialisation de la sélection provoquée par une mise à jour du modèle. Testez les commandes et contrôles exacts proposés par votre application.

Commencez par des identifiants stables et une liaison directe au texte. N’ajoutez une restauration explicite que pour les transitions qui l’exigent, et définissez une stratégie de sélection pour les remplacements par programmation. La modification ordinaire des libellés reste ainsi simple, et les limites liées à la suppression d’une ligne et au remplacement du texte sont clairement établies.

Sources