À la fin d’un glissement sur un canevas, calculez la géométrie finale de l’élément dans le système de coordonnées de la cible. Vérifiez d’abord l’inclusion complète, puis le chevauchement sur une surface positive. Ce tutoriel met cette règle en œuvre pour des éléments rectangulaires dans votre propre app Mac et explique l’équivalent AppKit.

Définir le chevauchement et l’inclusion

Considérez un canevas de bureau simple avec un rectangle bleu déplaçable et une cible grise fixe. La tâche consiste à indiquer si l’élément relâché tient entièrement dans la cible, la chevauche partiellement ou ne partage aucune surface avec elle. Tout ce qui figure dans l’exemple est synthétique et stocké en mémoire.

L’inclusion est directionnelle : la cible doit contenir l’élément. Apple indique que CGRectContainsRect(target, item) renvoie true lorsque l’union des rectangles est égale à la cible. Cela inclut un élément qui correspond exactement à la cible. Vérifier l’inclusion en premier évite de signaler comme chevauchement partiel un élément entièrement inclus. Documentation de CGRectContainsRect

Dans cet exemple, un chevauchement exige une région commune de largeur et de hauteur positives. Un simple contact par un bord ou un coin compte comme No area overlap. Calculez l’intersection et examinez ses dimensions plutôt que de laisser le comportement en cas de contact implicite. Apple indique que CGRectIntersection renvoie le rectangle commun, ou un rectangle nul en l’absence d’intersection. Documentation de CGRectIntersection

Résultat Règle appliquée dans ce tutoriel Exemple
Contained La cible contient l’élément entier, y compris lorsque leurs limites coïncident Un élément tient dans la cible ou lui correspond exactement
Overlap L’inclusion échoue, mais la région commune a une largeur et une hauteur positives L’élément dépasse le bord de la cible
No area overlap Il n’y a pas d’intersection de surface positive L’élément est séparé ou touche simplement un bord
Invalid geometry L’un des rectangles comporte des coordonnées non finies ou des dimensions non positives Un élément de largeur nulle

Il s’agit de règles propres à l’application, établies à partir des opérations géométriques. Si votre interface exige une marge à l’intérieur de la cible, définissez cette marge séparément et effectuez le test sur un rectangle d’acceptation plus petit. Veillez à ce que la cible visible et la règle d’acceptation soient compréhensibles pour la personne qui fait glisser l’élément.

Utiliser un seul système de coordonnées et la translation finale du glissement

Le rectangle que vous dessinez et celui que vous classez doivent décrire la même position. Dans cet exemple, l’élément et la cible utilisent tous deux les coordonnées du canevas. Le canevas reçoit un système de coordonnées nommé, et le DragGesture de l’élément utilise ce même nom. Le modificateur actuel coordinateSpace(_:) d’Apple prend en charge cette approche sous macOS 14 et versions ultérieures. Documentation sur les systèmes de coordonnées nommés

Stockez séparément le rectangle validé de l’élément et sa translation temporaire pendant le glissement. Pendant le geste, dessinez le rectangle validé décalé selon la translation. À la fin réussie du geste, recalculez ce rectangle final à partir de la valeur transmise par le rappel, classez-le, puis validez-le une seule fois.

Apple définit translation comme le déplacement total entre le début du glissement et l’événement courant. Par conséquent, ajouter sans cesse toute la translation à une position déjà mise à jour accumulerait un déplacement excessif. Appliquez-la plutôt à la position validée qui n’a pas changé. Documentation sur la translation du glissement

Le seul point de relâchement ne permet pas de savoir si l’élément entier tient dans une cible. Une personne peut saisir un rectangle près d’un coin, en laissant une grande partie à l’extérieur même si le pointeur se trouve à l’intérieur. Appliquer la translation préserve également le décalage initial de la prise sans déplacer le centre de l’élément sous le pointeur.

Créer un exemple SwiftUI complet avec des rectangles

Créez dans Xcode une app SwiftUI pour macOS dont la cible de déploiement est macOS 14 ou une version ultérieure. Remplacez le point d’entrée généré de l’app par les deux blocs ci-dessous, placés l’un à la suite de l’autre dans un seul fichier Swift. Supprimez le point d’entrée original afin que la cible ne contienne qu’une seule déclaration @main. Les deux blocs forment ensemble le programme complet.

Le premier bloc définit le classificateur géométrique. Il rejette explicitement les dimensions invalides au lieu de les convertir silencieusement en un autre rectangle. Une énumération Swift distingue les quatre résultats possibles ; ses valeurs brutes de type chaîne fournissent les messages affichés. Documentation sur les énumérations Swift

import SwiftUI
import CoreGraphics

enum Placement: String {
    case contained = "Contained"
    case overlap = "Overlap"
    case separate = "No area overlap"
    case invalid = "Invalid geometry"
}

func isUsable(_ rect: CGRect) -> Bool {
    let values = [rect.origin.x, rect.origin.y,
                  rect.width, rect.height,
                  rect.maxX, rect.maxY]
    return values.allSatisfy { $0.isFinite }
        && rect.width > 0 && rect.height > 0
}

func classify(item: CGRect, target: CGRect) -> Placement {
    guard isUsable(item), isUsable(target) else {
        return .invalid
    }
    if CGRectContainsRect(target, item) {
        return .contained
    }
    let shared = CGRectIntersection(item, target)
    if !shared.isNull && shared.width > 0 && shared.height > 0 {
        return .overlap
    }
    return .separate
}

Le second bloc fournit l’app et le canevas. Attachez le geste au rectangle déplaçable, comme le décrit la documentation d’Apple sur DragGesture. La taille fixe du canevas rend explicite la géométrie de cet exemple. Documentation de DragGesture

@main
struct RectanglePlacementApp: App {
    var body: some Scene {
        WindowGroup {
            PlacementCanvas()
        }
    }
}

struct PlacementCanvas: View {
    private let target = CGRect(x: 200, y: 100,
                                width: 200, height: 160)
    @State private var item = CGRect(x: 40, y: 120,
                                     width: 80, height: 60)
    @State private var message = "Drag the blue item, then release."
    @GestureState private var translation: CGSize = .zero

    private var preview: CGRect {
        item.offsetBy(dx: translation.width,
                      dy: translation.height)
    }

    private var move: some Gesture {
        DragGesture(minimumDistance: 1,
                    coordinateSpace: .named("placementCanvas"))
            .updating($translation) { value, state, _ in
                state = value.translation
            }
            .onEnded { value in
                let finalItem = item.offsetBy(
                    dx: value.translation.width,
                    dy: value.translation.height
                )
                let result = classify(item: finalItem, target: target)
                message = result.rawValue
                if result != .invalid {
                    item = finalItem
                }
            }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(message)
            ZStack(alignment: .topLeading) {
                Color.white
                Rectangle()
                    .fill(Color.gray.opacity(0.25))
                    .frame(width: target.width, height: target.height)
                    .position(x: target.midX, y: target.midY)
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: item.width, height: item.height)
                    .position(x: preview.midX, y: preview.midY)
                    .gesture(move)
            }
            .frame(width: 640, height: 380)
            .coordinateSpace(.named("placementCanvas"))
            Button("Reset") {
                item = CGRect(x: 40, y: 120, width: 80, height: 60)
                message = "Drag the blue item, then release."
            }
        }
        .padding()
    }
}

Faites glisser l’élément bleu au-dessus de la cible grise et relâchez-le. Le message indique le dernier placement terminé. Les chevauchements partiels et les placements séparés restent à l’endroit où vous les avez relâchés ; l’inclusion ne change pas automatiquement la relation parent-enfant et n’aimante pas l’élément dans un autre conteneur. Le bouton Reset rétablit le rectangle de départ.

Le rappel de mise à jour du geste ne modifie volontairement pas la position persistante. @GestureState ne contient que la translation d’aperçu et se réinitialise lorsque le geste devient inactif. Apple précise que onEnded n’est exécuté qu’après la réussite du geste, et recommande l’état du geste pour les valeurs qui doivent se réinitialiser quelle que soit la manière dont le geste se termine. Par conséquent, un geste interrompu peut abandonner l’aperçu tout en préservant le rectangle validé et le résultat précédent. Documentation de GestureState, Documentation de onEnded

Cette implémentation considère que la géométrie correspond aux rectangles remplis. L’ajout d’une ombre décorative ou d’un contour n’agrandit pas le rectangle d’acceptation. Si vous ajoutez le redimensionnement, le zoom ou la rotation, mettez à jour le modèle géométrique et la conversion des coordonnées avant de vous fier au même classificateur.

Vérifier les cas limites avant d’ajouter d’autres éléments

Utilisez les cas synthétiques suivants avec la cible de l’exemple : origine (200, 100), largeur 200, hauteur 160. Les coordonnées décrivent des rectangles d’élément dans le même espace de coordonnées que le canevas.

Origine de l’élément Taille de l’élément Résultat attendu Motif
(220, 120) 80 × 60 Contained Tous les bords se trouvent à l’intérieur de la cible
(360, 120) 80 × 60 Overlap L’élément dépasse le bord droit de la cible
(400, 120) 80 × 60 No area overlap Seuls les bords verticaux se touchent
(401, 120) 80 × 60 No area overlap Un espace sépare les rectangles
(200, 100) 200 × 160 Contained Des rectangles égaux satisfont la condition d’inclusion
(180, 80) 240 × 200 Overlap L’élément englobe la cible, ce qui inverse le sens de l’inclusion

Les calculs du tableau ont été vérifiés au moyen d’un calcul déterministe indépendant. Le programme Swift a été examiné, mais pas compilé dans l’environnement de recherche, qui ne disposait pas de compilateur Swift. Compilez-le dans la cible de l’app macOS et testez des positions de relâchement près de chaque limite.

Pour plusieurs éléments en mémoire, préservez l’identité de l’élément sélectionné au début du glissement. À la fin, comparez sa géométrie finale aux cibles admissibles et excluez l’élément lui-même. Si plusieurs cibles conviennent, définissez une règle de sélection stable, par exemple un ordre explicite des cibles. Le classement géométrique à lui seul ne détermine pas quelle cible doit accueillir l’élément.

Appliquer la même règle géométrique dans un canevas AppKit

Pour une vue AppKit personnalisée existante, gardez le classificateur indépendant de la distribution des événements. mouseUp(with:) signale le relâchement du bouton gauche de la souris ; utilisez-le pour finaliser uniquement un glissement déjà pris en charge par votre canevas. Un relâchement de souris sans rapport ne doit pas déclencher de placement. Documentation AppKit sur le relâchement de la souris

Voici une séquence pratique d’événements :

  1. Dans mouseDown(with:), sélectionnez un élément synthétique à l’aide de la règle de détection de cible existante du canevas. Enregistrez son identité, son rectangle de départ et la position de départ du pointeur.
  2. Convertissez chaque position pertinente d’événement souris en coordonnées du canevas. Pour une vue rattachée à sa fenêtre, l’expression de conversion est convert(event.locationInWindow, from: nil).
  3. Dans mouseDragged(with:), calculez un aperçu à partir du rectangle enregistré et du déplacement du pointeur.
  4. Dans mouseUp(with:), recalculez le déplacement à l’aide de l’événement de relâchement, classez le rectangle final, puis validez-le une seule fois et effacez le glissement actif.

Apple indique que locationInWindow est la position d’un événement souris dans les coordonnées de la fenêtre. La surcharge de conversion de points NSView.convert(_:from:) effectue une conversion depuis les coordonnées de la fenêtre lorsque son argument de vue est nil. Cela évite de comparer un point de la fenêtre à un rectangle exprimé dans les coordonnées locales du canevas. Documentation sur la position de l’événement, Documentation sur la conversion de points

Confiez la gestion des événements au composant qui possède déjà l’interaction. Apple distingue une sous-classe directe de NSView qui gère un événement d’une sous-classe qui doit laisser son parent gérer les événements non consommés. Respectez le comportement documenté du mécanisme de réponse ; l’ajout de la logique de placement ne justifie pas d’intercepter la séquence d’une autre vue ni de traiter le même relâchement dans deux gestionnaires indépendants. Conseils d’Apple sur la gestion des événements dans NSView

Savoir quand les rectangles ne suffisent pas à répondre à une question de forme

L’exemple fournit une réponse exacte pour les rectangles remplis alignés sur les axes. Pour les cercles, les contours concaves, les trous ou les formes pivotées, les rectangles englobants ne constituent qu’un test géométrique préliminaire. Leur surface commune peut inclure un espace qui n’est rempli par aucune des deux formes visibles.

Un test indiquant si un point se trouve dans un tracé ne peut pas non plus établir qu’une autre forme y est entièrement incluse. NSBezierPath.contains(_:) d’AppKit prend un seul point dans le système de coordonnées du tracé, et Apple précise que cette méthode ne tient pas compte de l’épaisseur du trait. Tester le centre d’un élément indique donc uniquement si ce centre se trouve dans le tracé. Documentation de NSBezierPath sur l’inclusion d’un point

Pour les tracés remplis sous macOS 14 et versions ultérieures, SwiftUI documente des opérations booléennes prises en charge : Path.intersection(_:eoFill:) produit la région remplie commune, et Path.subtracting(_:eoFill:) produit la partie de la première région remplie située à l’extérieur de la seconde. Ces opérations offrent une approche basée sur les tracés pour examiner le chevauchement et la partie d’un élément située à l’extérieur d’une cible. Alignez les deux tracés dans le même système de coordonnées et utilisez la règle de remplissage qui correspond à votre rendu. Documentation sur l’intersection de tracés, Documentation sur la soustraction de tracés

Ces opérations portent sur des régions remplies et supposent que les sous-tracés ouverts sont fermés. Une ligne tracée ou une règle d’application concernant le contact des limites exige sa propre définition géométrique. Conservez le classificateur rectangulaire pour les éléments rectangulaires ; faites évoluer délibérément la couche géométrique lorsque le canevas commence à dessiner d’autres formes.

Un résultat de relâchement fiable découle de trois choix concrets : préserver la géométrie de départ du glissement, calculer l’élément final dans le système de coordonnées de la cible et appliquer une règle explicite d’inclusion et de chevauchement. Une fois ces choix établis, l’interface peut signaler le résultat de manière cohérente sans coupler la géométrie à l’acheminement des événements ou à la propriété des éléments.

Sources