Associez le glissement du parent avec simultaneousGesture(_:including:) pour lui permettre de participer en même temps que les interactions des enfants. Conservez les actions de clic dans les boutons enfants, imposez un seuil de déplacement pour le glissement et décidez explicitement si la sélection peut accompagner un glissement.

Ce guide crée un panneau déplaçable contenant trois boutons d’éléments synthétiques. Le scénario est précis : cliquer sur un élément le sélectionne, tandis que faire glisser le panneau modifie sa position dans la même app Mac. L’exemple permet à la sélection et au déplacement de coexister ; une variante avec une poignée distincte offre des zones d’interaction séparées lorsque cela est préférable.

Choisir le contrat d’interaction avant de modifier la priorité des gestes

Apple décrit simultaneousGesture(_:including:) comme l’ajout d’un geste au même niveau de priorité que les gestes existants. Son exemple montre qu’un gestionnaire de toucher enfant et celui du conteneur englobant répondent tous deux à un clic. C’est le mécanisme de composition pertinent lorsque le parent doit participer sans prendre délibérément le pas sur l’enfant. Documentation d’Apple sur les gestes simultanés

Toutefois, la reconnaissance simultanée n’exprime pas à elle seule la règle « le glissement doit annuler toute action enfant ». L’exemple d’Apple n’établit pas non plus que tous les styles de boutons et tous les conteneurs arbitrent de la même manière entre un bouton et un glissement. Traitez la participation des gestes et le comportement de l’application comme deux décisions distinctes.

Dans ce panneau, le parent gère le décalage du panneau et l’élément sélectionné. Chaque enfant possède un bouton qui demande la sélection au moyen d’une fermeture. Une demande de sélection reste acceptable même si la même interaction du pointeur déplace le panneau. Si le comportement requis interdit l’activation pendant le déplacement, utilisez la variante avec une poignée distincte ci-dessous plutôt que de dépendre d’un ordre de rappel non documenté.

Utilisez un Button pour l’action enfant. Apple recommande explicitement les boutons lorsqu’une interaction nécessite le comportement d’un bouton, y compris son comportement d’accessibilité standard. Une forme personnalisée peut rester l’étiquette du bouton ; elle n’a pas besoin de devenir un simple geste de toucher. Documentation d’Apple sur les gestes

Créer un panneau déplaçable complet avec des boutons enfants

Créez une app SwiftUI pour macOS dans Xcode, définissez macOS 14 ou une version ultérieure comme cible de déploiement, puis remplacez le point d’entrée de l’app et la vue de contenu générés par le fichier unique suivant. Ne conservez qu’un seul point d’entrée d’app dans la cible. La version macOS 14 est requise pour le modificateur moderne coordinateSpace(_:) utilisé ici. Documentation d’Apple sur les espaces de coordonnées

Tous les éléments et tout l’état résident en mémoire. L’exemple déplace l’ensemble du panneau, boutons compris, dans une zone fixe. Il n’implémente pas le transfert d’éléments ni le déplacement de la fenêtre de l’application.

import SwiftUI

@main
struct GesturePanelApp: App {
    var body: some Scene {
        WindowGroup {
            PanelDemo()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

private struct ItemTarget: View {
    let item: DemoItem
    let isSelected: Bool
    let select: () -> Void

    var body: some View {
        Button(action: select) {
            HStack(spacing: 10) {
                Circle()
                    .fill(item.color)
                    .frame(width: 18, height: 18)
                Text(item.title)
                Spacer()
                if isSelected {
                    Text("Selected")
                        .font(.caption)
                }
            }
            .padding(12)
            .frame(width: 220)
            .background(
                isSelected
                    ? Color.accentColor.opacity(0.18)
                    : Color.gray.opacity(0.12)
            )
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

private struct PanelDemo: View {
    private let items = [
        DemoItem(id: 1, title: "Item A", color: .blue),
        DemoItem(id: 2, title: "Item B", color: .orange),
        DemoItem(id: 3, title: "Item C", color: .purple)
    ]

    @State private var selectedID: Int? = nil
    @State private var committedOffset: CGSize = .zero
    @GestureState private var liveTranslation: CGSize = .zero

    private var pan: some Gesture {
        DragGesture(
            minimumDistance: 8,
            coordinateSpace: .named("stage")
        )
        .updating($liveTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            committedOffset = CGSize(
                width: committedOffset.width + value.translation.width,
                height: committedOffset.height + value.translation.height
            )
        }
    }

    var body: some View {
        VStack(spacing: 12) {
            Text("Click an item or drag the panel")

            ZStack {
                Color.gray.opacity(0.06)

                VStack(spacing: 10) {
                    Text("Drag panel")
                        .font(.headline)
                        .frame(width: 244, height: 32)
                        .contentShape(Rectangle())

                    ForEach(items) { item in
                        ItemTarget(
                            item: item,
                            isSelected: selectedID == item.id,
                            select: { selectedID = item.id }
                        )
                    }
                }
                .padding(16)
                .background(Color.gray.opacity(0.15))
                .contentShape(Rectangle())
                .simultaneousGesture(pan, including: .all)
                .offset(
                    x: committedOffset.width + liveTranslation.width,
                    y: committedOffset.height + liveTranslation.height
                )
            }
            .frame(width: 640, height: 420)
            .coordinateSpace(.named("stage"))

            Button("Reset panel") {
                committedOffset = .zero
            }
        }
        .padding(20)
    }
}

Ce programme a été examiné à la lumière de la documentation d’API récupérée, mais il n’a pas été compilé ni testé de manière interactive ici : cet environnement ne dispose ni d’un compilateur Swift ni d’un environnement d’exécution de l’interface macOS. Compilez-le dans Xcode et effectuez les vérifications ci-dessous sur les versions de macOS que vous prenez en charge.

Pourquoi le glissement utilise un seuil supérieur à zéro

DragGesture expose une distance minimale de déplacement à parcourir avant que le geste soit reconnu. La valeur illustrative 8 tolère un léger mouvement lors des clics ordinaires avant que le parent ne commence à glisser. Il s’agit d’un choix de l’application, et non d’un seuil prescrit par Apple ni d’une politique complète distinguant clic et glissement. Un glissement à distance nulle supprime cette distinction particulière. Documentation d’Apple sur DragGesture

Pourquoi les déplacements temporaire et validé sont séparés

Pendant la reconnaissance, updating(_:body:) écrit la translation actuelle dans liveTranslation. Apple indique que cet état de geste est réinitialisé à la fin ou à l’annulation du geste. L’exemple ne valide le décalage final que dans onEnded ; ainsi, une annulation abandonne l’aperçu non validé au lieu de laisser une translation temporaire obsolète. Documentation d’Apple sur updating(_:body:)

La position affichée correspond au décalage validé plus la translation actuelle. Apple définit translation comme le déplacement total depuis le début du glissement ; l’ajouter de manière répétée au décalage validé à chaque changement ferait donc compter le mouvement plusieurs fois. À titre d’illustration, un décalage horizontal validé de 40 et une translation actuelle de 12 produisent un décalage affiché de 52 ; à la fin du glissement, 52 est validé une seule fois. Documentation d’Apple sur translation

L’espace de coordonnées nommé appartient à la zone fixe qui entoure le panneau mobile. Le calcul du mouvement se réfère ainsi à une région de référence explicite. Les boutons enfants ne conservent pas leur propre copie de la position du panneau.

Pourquoi la sélection reste gérée par le parent

Le parent stocke selectedID et transmet à chaque enfant son état de sélection actuel ainsi qu’une fermeture de sélection. Cela suit les conseils d’Apple : conserver l’état dans la vue la plus haute qui en a besoin, puis transmettre des valeurs ou des liaisons dans la hiérarchie. Documentation d’Apple sur State

La fermeture capture l’élément actuel et demande une mise à jour de l’état du parent. Swift permet aux fermetures de capturer des valeurs du contexte dans lequel elles sont définies ; aucun événement AppKit n’a besoin d’être copié ou transféré pour implémenter ce rappel. Le langage de programmation Swift : fermetures

Comprendre les zones de détection et les masques de gestes

contentShape(Rectangle()) définit la forme utilisée pour la détection des interactions. Dans l’étiquette du bouton, il suit le remplissage et le cadre afin de délimiter explicitement la région rectangulaire prévue pour l’étiquette. Sur le parent, il définit la forme d’interaction du panneau. Il ne définit pas la priorité des reconnaisseurs et n’impose pas la coopération de deux gestes. Documentation d’Apple sur contentShape

Le masque fourni à un modificateur de geste détermine quels gestes restent activés. Ces options sont faciles à mal interpréter : subviews signifie que le geste parent ajouté est désactivé, et non qu’il coopère automatiquement avec les descendants. Documentation d’Apple sur GestureMask

Masque Effet documenté Pertinence ici
.all Active le geste ajouté et les autres gestes de la vue et de ses sous-vues Point de départ pour une participation partagée
.gesture Active le geste ajouté et désactive les gestes dans la hiérarchie des sous-vues Incompatible avec la préservation des gestes enfants
.subviews Active les gestes des sous-vues et désactive le geste ajouté Désactive le glissement du parent
.none Désactive les gestes dans la hiérarchie des sous-vues, y compris le geste ajouté Désactive l’interaction prévue

Évitez de considérer highPriorityGesture comme une solution générale à l’absence de rappels de glissement du parent. Son rôle documenté est de donner une priorité supérieure au geste associé. L’exemple d’Apple montre le parent prenant le pas sur le toucher de l’enfant. Choisissez cette option lorsque cette priorité correspond au comportement voulu. Documentation d’Apple sur les gestes à priorité élevée

Utiliser une poignée de glissement distincte lorsque l’activation doit rester séparée

Pour un panneau dont les boutons ne doivent partager aucune zone de glissement, conservez le même état et le même calcul du mouvement, mais associez pan uniquement à l’en-tête. Supprimez .simultaneousGesture(pan, including: .all) du panneau parent.

Dans le programme complet ci-dessus, remplacez l’expression actuelle de l’en-tête par ce fragment. Il utilise la propriété pan déjà définie ; aucun autre prérequis ne change.

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

L’en-tête et les boutons d’élément occupent désormais des régions distinctes, et les boutons n’ont plus le geste de glissement du panneau sur l’un de leurs ancêtres. Commencer sur un élément déclenche une interaction avec le bouton ; commencer sur l’en-tête permet de déplacer le panneau. Cette conception est utile lorsque la participation partagée rendrait le résultat ambigu.

Conservez le bouton natif lorsque l’enfant déclenche une action. Apple décrit Button comme une action associée à une étiquette et permet d’utiliser des vues personnalisées comme étiquettes, comme pour les éléments colorés synthétiques. Le remplacer par un gestionnaire de pointeur créé à la main vous obligerait à recréer le comportement déjà fourni par le contrôle. Documentation d’Apple sur Button

Préserver la gestion des événements AppKit aux frontières entre frameworks

Si le panneau contient une vue AppKit, examinez sa détection des interactions séparément de la composition des gestes SwiftUI. hitTest(_:) d’AppKit renvoie le descendant correspondant le plus profond et aide la fenêtre à déterminer le destinataire d’un événement de clic de souris. Son point d’entrée est exprimé dans les coordonnées de la vue superordonnée. Documentation d’Apple sur la détection des interactions dans AppKit

Renvoyer un parent lors de la détection des interactions modifie la cible initiale ; cela n’équivaut pas à permettre la reconnaissance simultanée par un enfant et son parent. De même, renvoyer nil lors de la détection des interactions d’une superposition exclut cette dernière ; cela ne la rend pas observatrice passive de l’interaction complète de l’enfant.

AppKit indique que l’implémentation par défaut de mouseDown(with:) transmet le message au répondant suivant. Il s’agit du fonctionnement de la chaîne des répondants, et non de la garantie que chaque ancêtre reçoit indépendamment une copie après qu’un contrôle a traité l’événement. Reliez l’activation du contrôle et le déplacement du panneau au moyen de rappels explicites ou de reconnaisseurs que vous gérez. Documentation d’Apple sur mouseDown(with:)

Lorsque vous gérez deux reconnaisseurs de gestes AppKit et souhaitez une reconnaissance simultanée, AppKit fournit gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). Apple indique que renvoyer true permet aux deux reconnaisseurs de fonctionner ensemble ; renvoyer false ne garantit pas leur exclusion si le délégué de l’autre reconnaisseur l’autorise. Limitez cette règle à la paire de reconnaisseurs concernée. Documentation d’Apple sur le délégué des reconnaisseurs

Vérifier l’interaction avec une petite matrice de comportements

Testez le style exact du bouton, la hiérarchie et les versions de macOS prises en charge par votre app. Les vérifications suivantes permettent de distinguer les problèmes de reconnaissance des erreurs de calcul du mouvement, et de déterminer si la surface partagée correspond au contrat prévu.

Action Élément à vérifier
Cliquer sur chaque élément sans le faire glisser Son état de sélection change ; le panneau reste immobile
Faire glisser l’en-tête au-delà du seuil Le panneau suit le pointeur et conserve sa position finale
Faire glisser depuis un élément dans la version à surface partagée Le déplacement du parent fonctionne ; déterminez si le bouton s’active aussi et confirmez que cela convient
Effectuer deux glissements consécutifs Le second part de la position validée par le premier, sans cumuler chaque translation intermédiaire
Interrompre un glissement en cours La translation temporaire disparaît ; aucun déplacement inachevé n’est validé
Essayer la variante avec poignée Les éléments s’activent indépendamment ; le déplacement commence depuis l’en-tête
Activer les boutons des éléments au clavier La sélection fonctionne sans devoir effectuer un glissement avec le pointeur

Si un espace vide du panneau permet de le faire glisser correctement, mais pas les régions des éléments, examinez les contrôles enfants et la composition des gestes. Si les clics ne sont plus pris en compte, examinez les masques de gestes, les priorités et les zones de détection. Si le panneau accélère ou saute, vérifiez l’accumulation des translations et l’espace de coordonnées choisi avant d’ajouter d’autres reconnaisseurs.

L’exemple laisse volontairement le déplacement sans limites ; le panneau peut donc sortir de la zone visible. Le bouton Réinitialiser le panneau restaure sa position. Pour un espace de travail finalisé avec des limites, ajoutez une contrainte de position explicite fondée sur les dimensions de la zone et du panneau. Conservez la même structure de gestion : les enfants demandent des actions, le parent gère le déplacement et le contrat d’interaction détermine si leurs zones de reconnaissance se chevauchent.

Sources