Pour qu’une carte synthétique apparaisse intacte pendant un glisser-déposer natif sur macOS, ajoutez une seule source de glisser-déposer à son conteneur externe et affichez la carte complète comme aperçu. Utilisez AppKit lorsque vous avez besoin de contrôler explicitement le cadre initial de l’aperçu et le décalage par rapport au pointeur.

Ce guide explique comment faire glisser une carte en mémoire entre deux panneaux de votre propre app Mac. L’objectif pratique consiste à préserver les formes, le texte, l’espacement et l’identité de la carte, tout en confiant clairement la gestion du geste et les mises à jour du modèle.

Quelle partie du glisser-déposer faut-il modifier ?

Apple distingue les données transférées de la représentation visuelle affichée pendant le glisser-déposer. Le draggable(_:preview:) de SwiftUI génère un rendu de la vue d’aperçu au début du glisser-déposer. Selon la documentation, l’aperçu est centré sur la vue source. La vue source elle-même n’est pas transférée. Consultez la documentation Apple sur draggable(_:preview:).

Utilisez ce guide de décision pour repérer le contrôle dont vous avez réellement besoin :

Comportement souhaité Point de départ pris en charge
Garder ensemble le libellé, les formes et l’arrière-plan de la carte Une seule source externe de glisser-déposer avec un aperçu personnalisé complet
Ajuster le contour pendant la transition de soulèvement contentShape(.dragPreview, shape)
Modifier la partie de la carte qui peut déclencher le glisser-déposer contentShape(.interaction, shape)
Déplacer une carte vers un autre panneau Valider la charge utile et mettre à jour le modèle dans la destination
Définir explicitement le rectangle initial de l’image de glisser-déposer et sa relation avec le pointeur Une session gérée par AppKit avec NSDraggingItem
Déplacer un élément en continu dans une zone de dessin sans destinations natives de dépôt Une interaction locale avec DragGesture

La distinction entre les deux formes de contenu est importante : Apple indique que .dragPreview agit sur la forme de l’aperçu, tandis que .interaction contrôle le test de hit. Modifier uniquement le contour de l’aperçu n’agrandit pas la zone qui déclenche le glisser-déposer. Consultez ContentShapeKinds.dragPreview.

Un DragGesture local signale un mouvement de glissement au fil d’une séquence de gestes. Choisissez cette approche pour mettre à jour la position d’un élément dans une zone de dessin. Pour le glisser-déposer natif entre panneaux décrit dans cet article, laissez draggable installer les gestes de glissement. Consultez la référence Apple de DragGesture et le guide sur les sources de glisser-déposer.

Créer un seul aperçu pour toute la carte synthétique

Placez le modificateur de glisser-déposer sur le plus petit conteneur qui comprend tous les éléments devant se déplacer ensemble. Pour une carte composée d’un cercle, de deux lignes de texte et d’un arrière-plan arrondi, ce conteneur est la carte entière. Attacher des sources de glisser-déposer séparées à ses enfants définirait des interactions distinctes.

Réutilisez une même vue de présentation pour la source et l’aperçu. Donnez-lui des dimensions explicites et son propre arrière-plan, au lieu de dépendre du style hérité d’un panneau environnant. Il s’agit d’une recommandation de conception déduite du comportement de rendu de l’aperçu : une présentation autonome facilite la correspondance entre les deux apparences.

Exemple SwiftUI complet

Créez une app SwiftUI pour macOS dont la cible de déploiement est macOS 13 ou une version ultérieure. Remplacez le point d’entrée de l’app généré et la vue de contenu par ce fichier unique, en veillant à ne conserver qu’une seule déclaration @main dans la cible. Le modificateur d’aperçu personnalisé et la surcharge de destination utilisés ici sont documentés comme étant disponibles à partir de macOS 13. Consultez draggable(_:preview:) et dropDestination(for:action:isTargeted:).

import Foundation
import SwiftUI

@main
struct IntactDragDemoApp: App {
    var body: some Scene {
        WindowGroup {
            BoardView()
        }
    }
}

private struct CardFace: View {
    var body: some View {
        HStack(spacing: 14) {
            Circle()
                .fill(Color.orange)
                .frame(width: 36, height: 36)

            VStack(alignment: .leading, spacing: 6) {
                Text("Tile A")
                    .font(.headline)
                Text("Circle and label")
                    .font(.caption)
            }
            .foregroundStyle(Color.white)
        }
        .padding(16)
        .frame(width: 220, height: 100, alignment: .leading)
        .background(Color.indigo)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

private struct BoardView: View {
    @State private var token = UUID().uuidString
    @State private var isOnRight = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Drag the card between panels")
                .font(.headline)

            HStack(spacing: 24) {
                panel(right: false)
                panel(right: true)
            }
        }
        .padding(24)
    }

    private func panel(right: Bool) -> some View {
        VStack(spacing: 16) {
            Text(right ? "Right" : "Left")
                .font(.headline)

            ZStack {
                if isOnRight == right {
                    CardFace()
                        .contentShape(
                            .interaction,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .contentShape(
                            .dragPreview,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .draggable(token) {
                            CardFace()
                        }
                } else {
                    Text("Drop here")
                        .foregroundStyle(.secondary)
                }
            }
            .frame(width: 240, height: 120)
        }
        .frame(width: 260, height: 180)
        .background(
            RoundedRectangle(cornerRadius: 20)
                .fill(Color.gray.opacity(0.12))
        )
        .contentShape(Rectangle())
        .dropDestination(for: String.self) { items, _ in
            guard items == [token] else {
                return false
            }
            isOnRight = right
            return true
        }
    }
}

Cet exemple utilise uniquement des éléments graphiques synthétiques et un identifiant généré en mémoire. String est déjà conforme à Transferable : aucune représentation de transfert personnalisée n’est donc nécessaire. Apple documente cette conformité intégrée dans Transformer une vue en source de glisser-déposer.

L’interaction attendue est simple : faites glisser Tile A vers l’autre panneau, relâchez-le, puis faites-le glisser en retour. La vue complète CardFace constitue l’aperçu qui se déplace. La destination accepte un seul jeton correspondant et modifie l’unique valeur de l’emplacement. Renvoyer true signale que le dépôt a réussi ; renvoyer false le rejette, conformément au contrat documenté de l’action de destination.

Cet exemple complet est destiné à une cible d’app macOS. Il n’a pas été compilé ni exécuté ici, car l’environnement de recherche ne dispose ni d’une chaîne d’outils Swift ni d’un environnement d’exécution macOS.

Distinguer la continuité visuelle de la gestion du modèle

Dans l’exemple, la carte d’origine reste visible jusqu’à ce qu’un dépôt accepté modifie isOnRight. Un relâchement en dehors des panneaux ne change pas le modèle. Déposer la carte dans le panneau qu’elle occupe déjà réattribue son emplacement actuel. Un jeton incompatible ou plusieurs éléments sont rejetés avant toute modification de l’état.

Ce comportement évite de supprimer la carte au début du glisser-déposer, puis de devoir la recréer en cas d’annulation. L’aperçu assure la continuité visuelle ; la destination gère le changement d’emplacement. Pour un tableau plus grand, appliquez le même modèle à un identifiant d’élément stable et à une collection faisant autorité.

La sémantique de valeur de Swift explique pourquoi le transfert d’une charge utile ne déplace pas automatiquement le modèle d’origine. Les structures et les chaînes ont une sémantique de valeur ; leur affectation ou leur transmission ne déplace pas implicitement un élément dans la collection de votre application. La mise à jour de votre modèle doit exprimer ce déplacement. Consultez Structures et classes.

Distinguez également un déplacement au niveau de l’application de l’opération native de glisser-déposer. L’exemple modifie un état partagé du tableau après un dépôt accepté ; il ne configure pas la négociation native d’un déplacement et ne garantit pas l’affichage d’un curseur de déplacement.

À partir de macOS 26, Apple documente DragConfiguration pour déclarer les opérations prises en charge et onDragSessionUpdated(_:) pour observer l’opération effectuée. Une destination choisit l’opération qu’elle exécute. Si vous supprimez l’élément de la source après un déplacement natif, coordonnez cette suppression avec le traitement de la destination pour éviter que le même élément partagé soit supprimé deux fois. Consultez DragConfiguration et onDragSessionUpdated(_:).

Quand AppKit doit-il gérer la session de glisser-déposer ?

Utilisez AppKit lorsque le critère d’acceptation exige une image de glisser-déposer positionnée explicitement, par exemple pour que le pointeur reste au même endroit dans la carte au moment de la prise en main. Le modificateur d’aperçu personnalisé documenté par SwiftUI prend une charge utile et une vue d’aperçu, et décrit un aperçu centré. AppKit expose directement le cadre de l’élément de glissement.

Pour une implémentation AppKit, procédez ainsi :

  1. Laissez la vue source NSView recevoir le véritable événement de clic de souris et suivre si l’interaction devient un glisser-déposer.
  2. Dessinez la carte synthétique complète dans une seule NSImage et créez un unique NSDraggingItem avec son rédacteur de presse-papiers.
  3. Définissez l’image et son rectangle initial avec setDraggingFrame(_:contents:).
  4. Exprimez ce rectangle dans le système de coordonnées de la vue qui a lancé l’interaction, puis appelez beginDraggingSession(with:event:source:) avec l’événement de clic initial et votre NSDraggingSource.
  5. Laissez la destination valider et effectuer le dépôt. Utilisez le rappel d’achèvement de la source pour nettoyer la session.

Apple précise que la position de la souris dans l’événement initial détermine le décalage de l’icône déplacée et que le cadre initial de chaque élément doit utiliser les coordonnées de la vue source. Gardez le rectangle de la carte et la position de l’événement dans un système de coordonnées cohérent. Consultez beginDraggingSession(with:event:source:).

setDraggingFrame(_:contents:) est la méthode de commodité documentée pour un élément de glissement représenté par une seule image. Une carte synthétique peut contenir plusieurs éléments dessinés tout en restant représentée par cette image unique. Consultez setDraggingFrame(_:contents:).

Préserver les limites des événements et des sessions

Choisissez un seul initiateur de glissement pour la zone interactive de la carte. Comme règle d’implémentation, évitez de superposer le lancement d’une session AppKit à une source draggable SwiftUI active sur cette même zone. Les deux tenteraient de gérer la même interaction. Ne fabriquez pas d’événements de souris pour compenser une gestion ambiguë des gestes.

Vous pouvez intégrer la source AppKit avec NSViewRepresentable. Conservez la gestion des événements natifs dans son NSView et communiquez les modifications du modèle acceptées au moyen de liaisons ou d’un coordinateur. SwiftUI contrôle la mise en page de la vue représentée ; Apple précise de ne pas définir directement frame ou bounds sur cette vue gérée. Le rectangle de l’image d’un élément de glissement est une question distincte. Consultez NSViewRepresentable.

Ne conservez pas un NSDraggingItem pour le modifier ultérieurement. Apple documente sa durée de vie limitée : après la création de la session, modifiez les éléments de glissement au moyen de l’énumération de la session et n’utilisez chaque élément énuméré qu’à l’intérieur de son rappel. Les modifications apportées du côté de la destination peuvent également remplacer celles apportées du côté de la source pendant que le glissement se trouve au-dessus de cette destination. Consultez NSDraggingItem et enumerateDraggingItems(options:for:classes:searchOptions:using:).

Comment diagnostiquer un aperçu qui ne s’affiche toujours pas correctement ?

Vérifiez le problème à l’étape où il se produit :

Symptôme Première vérification
Seul le cercle ou le texte se déplace Placez une seule source de glissement sur le conteneur complet de la carte.
Les dimensions de l’aperçu diffèrent de celles de la carte Réutilisez la même vue de présentation et les mêmes dimensions explicites.
Le contour au moment de la prise en main diffère de l’image en mouvement Examinez séparément .dragPreview et la closure d’aperçu personnalisé.
Un espace vide déclenche le glissement de façon inattendue Vérifiez .interaction et les limites du conteneur source.
L’image AppKit saute au moment de la prise en main Vérifiez l’événement de clic initial et les coordonnées de la vue source.
Les modifications ultérieures de l’image AppKit n’ont aucun effet Effectuez-les dans l’énumération de la session.
La carte disparaît après une annulation Déplacez la mutation du modèle vers le traitement d’un dépôt accepté et vérifiez le nettoyage.

Vérifiez séparément l’annulation, un dépôt accepté, un dépôt dans le panneau d’origine et le rejet d’une charge utile. L’invariant utile est qu’un seul emplacement du modèle reste la source de vérité tout au long de l’opération. Commencez par l’aperçu SwiftUI complet, puis adoptez une source gérée par AppKit uniquement lorsque le positionnement requis de l’image nécessite un contrôle explicite de son cadre et des événements.

Sources