Pour sélectionner des formes dans un canevas SwiftUI sur Mac, lisez la position du pointeur dans l’espace de coordonnées de la vue du canevas, puis inversez la transformation utilisée pour le dessin. Avec le zoom et le panoramique, soustrayez le décalage du panoramique et divisez par le zoom avant de tester vos formes.
Ce tutoriel construit un petit canevas de bureau contenant deux rectangles synthétiques. Vous mettrez en évidence le rectangle sous le pointeur et sélectionnerez un rectangle à la fin d’un geste du pointeur, y compris après un changement de zoom ou de panoramique. Toutes les données de dessin restent en mémoire.
Quel espace de coordonnées doit recevoir les événements du pointeur ?
Distinguez trois espaces : le système de coordonnées qui stocke vos formes, le système de coordonnées local de la vue du canevas et toutes les coordonnées de fenêtre fournies par AppKit. Un point n’est utile que si vous savez à quel espace il appartient.
Le onContinuousHover(coordinateSpace:perform:) de SwiftUI fournit les positions du pointeur dans l’espace de coordonnées demandé ; sa valeur par défaut est .local. Il délivre .active(location) lorsque le pointeur se trouve dans la vue et .ended lorsqu’il en sort. Attachez-le directement au canevas afin que son espace local corresponde à la zone d’affichage du dessin. Documentation d’Apple sur le survol continu.
Un espace de coordonnées nommé est utile lorsqu’un ancêtre gère intentionnellement la zone d’interaction. Les gestionnaires de survol et de geste doivent alors demander le même espace nommé. Apple définit .local, .global et .named(_:) comme des espaces de coordonnées de mise en page ; nommer un espace ne définit pas le modèle de dessin de l’application. Documentation d’Apple sur les espaces de coordonnées.
Cette distinction est importante, car Canvas effectue un dessin en mode immédiat. Apple indique explicitement que les éléments dessinés individuellement, y compris les symboles, ne reçoivent pas leur propre interactivité. L’approche prise en charge ici consiste à gérer l’interaction sur le canevas conteneur et à effectuer vous-même le test de collision avec le modèle. Documentation d’Apple sur Canvas.
Comment inverser la transformation de dessin ?
Choisissez une convention de dessin explicite. Dans cet exemple, les coordonnées du modèle augmentent vers la droite et vers le bas. Le zoom met à l’échelle les unités du modèle, et le panoramique est un décalage mesuré en points locaux du canevas. Le mappage direct et son inverse sont les suivants :
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
Ces équations découlent du mappage choisi. Pour le dessiner, translatez le contexte graphique selon le panoramique, puis mettez-le à l’échelle selon le zoom. Apple indique que ces méthodes mettent à jour la transformation du contexte et affectent les opérations de dessin suivantes. Inverser leur ordre modifie le mappage ; associez donc l’inverse à la séquence de dessin exacte. Documentation sur la translation, documentation sur la mise à l’échelle.
Pour une vérification illustrative, utilisez le zoom 2, le panoramique (40, 30) et une position locale du pointeur (180, 110). La soustraction du panoramique donne (140, 80) ; la division par le zoom donne la position du modèle (70, 40). Le dessin de ce point du modèle le ramène à (180, 110). Un rectangle dont le cadre dans le modèle contient (70, 40) doit donc être détecté.
Conservez ce calcul dans un type partagé unique. Le moteur de rendu et le code d’interaction doivent utiliser le même mappage au lieu de maintenir des formules de décalage distinctes. L’exemple n’accepte que des valeurs de zoom positives et finies ; zéro ne peut pas être inversé.
Créer un exemple complet de survol et de sélection
Utilisez un projet d’application SwiftUI pour macOS avec une cible de déploiement macOS 14 ou ultérieure. Cette cible prend en charge la surcharge CoordinateSpaceProtocol utilisée par le modificateur de survol. Ajoutez le fichier de modèle suivant, puis remplacez la déclaration d’application générée par le deuxième fichier. La cible doit contenir exactement une déclaration @main. Disponibilité et signature de l’API de survol.
Définir la géométrie du modèle et son mappage
Créez CanvasGeometry.swift avec le contenu complet suivant. Les cadres des rectangles restent dans les coordonnées du modèle pendant toute l’interaction.
import SwiftUI
struct CanvasMapping {
let zoom: CGFloat
let pan: CGSize
init(zoom: CGFloat, pan: CGSize) {
precondition(zoom.isFinite && zoom > 0)
precondition(pan.width.isFinite && pan.height.isFinite)
self.zoom = zoom
self.pan = pan
}
func modelPoint(from viewPoint: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - pan.width) / zoom,
y: (viewPoint.y - pan.height) / zoom
)
}
func apply(to context: inout GraphicsContext) {
context.translateBy(x: pan.width, y: pan.height)
context.scaleBy(x: zoom, y: zoom)
}
}
struct CanvasItem {
let id: Int
let frame: CGRect
}
La géométrie et le mappage sont des structures Swift. Leur sémantique de valeur facilite la transmission des valeurs actuelles au dessin et au test de collision sans introduire un autre objet mutable partagé. Le guide du langage Swift décrit les structures comme des types valeur. Structures et classes.
Dessiner et interagir dans la même zone d’affichage
Créez PointerCanvasApp.swift en remplaçant la déclaration d’application générée. Ce fichier complet utilise les types de CanvasGeometry.swift.
import SwiftUI
@main
struct PointerCanvasApp: App {
var body: some Scene {
WindowGroup {
PointerCanvasView()
}
}
}
struct PointerCanvasView: View {
private let items = [
CanvasItem(id: 1, frame: CGRect(
x: 40, y: 30, width: 120, height: 80)),
CanvasItem(id: 2, frame: CGRect(
x: 120, y: 70, width: 120, height: 80))
]
@State private var zoom: CGFloat = 2
@State private var pan = CGSize(width: 40, height: 30)
@State private var pointerInView: CGPoint?
@State private var selectedID: Int?
private var mapping: CanvasMapping {
CanvasMapping(zoom: zoom, pan: pan)
}
private func hit(at viewPoint: CGPoint) -> Int? {
let point = mapping.modelPoint(from: viewPoint)
return items.reversed().first {
$0.frame.contains(point)
}?.id
}
var body: some View {
let currentMapping = mapping
let hoveredID = pointerInView.flatMap { hit(at: $0) }
VStack(alignment: .leading, spacing: 12) {
HStack {
Button("Zoom out") {
zoom = max(0.5, zoom / 2)
}
Button("Zoom in") {
zoom = min(4, zoom * 2)
}
Button("Pan right") {
pan.width += 20
}
Button("Reset view") {
zoom = 2
pan = CGSize(width: 40, height: 30)
}
Text("Selected: \(selectedID.map { String($0) } ?? "none")")
}
Canvas { context, _ in
var drawing = context
currentMapping.apply(to: &drawing)
for item in items {
let path = Path(item.frame)
drawing.fill(
path,
with: .color(
item.id == hoveredID ? .orange : .blue
)
)
if item.id == selectedID {
drawing.stroke(
path,
with: .color(.black),
lineWidth: 2 / currentMapping.zoom
)
}
}
}
.background(Color.white)
.contentShape(Rectangle())
.onContinuousHover(coordinateSpace: .local) { phase in
switch phase {
case .active(let point):
pointerInView = point
case .ended:
pointerInView = nil
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onEnded { value in
selectedID = hit(at: value.location)
}
)
.clipped()
}
.padding(16)
.frame(minWidth: 640, minHeight: 420)
}
}
Le canevas utilise une zone d’interaction rectangulaire, y compris l’espace vide entre les formes. contentShape(Rectangle()) définit la forme utilisée pour le test de collision de la vue ; le test frame.contains distinct détermine quel élément synthétique se trouve sous le pointeur. Documentation d’Apple sur contentShape.
La sélection utilise un DragGesture dont la distance minimale est nulle et lit le location de l’événement de fin. Le comportement défini consiste à sélectionner le rectangle situé sous la position de relâchement, même si le pointeur a bougé pendant la pression. Il s’agit d’un sélecteur fondé sur la position de relâchement, et non d’un détecteur strict de clic. Apple documente le seuil de distance du geste et sa configuration d’espace de coordonnées, et définit location comme la position de l’événement actuel. DragGesture, DragGesture.Value.
Les éléments sont dessinés dans l’ordre du tableau et testés dans l’ordre inverse. Ainsi, le rectangle 2 l’emporte là où les deux se chevauchent, conformément à l’empilement visuel de cet exemple. Un clic dans un espace vide efface volontairement la sélection. Les cadres des formes et le contenu du tableau ne changent jamais.
La vue possède l’état du pointeur et de la sélection, tandis que le moteur de rendu le lit. Apple recommande de déclarer l’état privé dans la vue la plus haute qui en a besoin et de transmettre des liaisons lorsque les vues descendantes doivent pouvoir le modifier. Si un parent prend ensuite en charge la collection d’éléments ou la sélection, déplacez-y leur propriété et transmettez les valeurs ou liaisons nécessaires à cette vue. Documentation d’Apple sur l’état.
Comment vérifier le mappage avant d’ajouter d’autres gestes ?
Commencez par des points connus du modèle et calculez leurs positions locales attendues. Ces points de contrôle utilisent le zoom et le panoramique initiaux de l’exemple :
| Point du modèle | Point local attendu dans le canevas | Résultat attendu |
|---|---|---|
(70, 40) |
(180, 110) |
Rectangle 1 |
(140, 90) |
(320, 210) |
Rectangle 2, dans la zone de chevauchement |
(10, 10) |
(60, 50) |
Aucun rectangle |
Les positions locales sont mesurées depuis le canevas lui-même ; la barre d’outils et le remplissage extérieur n’appartiennent pas à cet espace. Déplacez-vous sur les deux rectangles, relâchez au-dessus de leur zone de chevauchement, puis essayez Zoom out et Pan right. La mise en évidence doit suivre la géométrie visible à chaque réglage.
Le calcul inverse illustratif et 12 vérifications aller-retour direct/inverse ont été exécutés avec des opérations arithmétiques en Python. Un compilateur Swift et un environnement d’exécution de l’interface macOS n’étaient pas disponibles dans l’environnement de recherche ; l’application Swift a donc été examinée, mais ni compilée ni testée de manière interactive.
Si une vérification échoue, utilisez le symptôme pour cerner la cause :
- Un décalage constant suggère que le gestionnaire d’entrée appartient à une autre vue ou que le panoramique a été omis.
- Une erreur qui augmente avec la distance suggère une conversion d’échelle manquante ou un ordre de transformation incohérent.
- Un résultat inversé verticalement suggère un retournement supplémentaire de l’axe y ou un point AppKit non converti.
- Une sélection incorrecte uniquement dans les zones de chevauchement suggère un ordre différent entre le dessin et le test de collision.
Stockez le point de survol local brut, comme le fait l’exemple, et déduisez le point du modèle à partir du mappage actuel. Vous éviterez ainsi de conserver une position de survol dans le modèle calculée avec un zoom antérieur. La sélection utilise la position propre à l’événement du geste et ne dépend donc pas d’un rappel de survol précédent.
Qu’est-ce qui change lorsque l’événement provient d’AppKit ?
Si une vue AppKit personnalisée existante gère déjà l’interaction avec la souris, convertissez l’événement à cette frontière avant d’appliquer l’inverse du dessin. NSEvent.locationInWindow utilise normalement les coordonnées de base de la fenêtre associée. Apple précise qu’un événement associé à une fenêtre nil peut plutôt contenir des coordonnées d’écran ; vérifiez donc que l’événement appartient à la fenêtre de la vue qui le reçoit. Documentation d’Apple sur la position de l’événement.
Dans cette NSView réceptrice, l’expression convert(event.locationInWindow, from: nil) convertit un point de la fenêtre en coordonnées locales de la vue. Apple décrit nil comme la source de coordonnées de fenêtre pour cette conversion. Appliquez ensuite seulement l’inverse de la transformation utilisée par le moteur de rendu de cette vue. Documentation d’Apple sur la conversion de points.
Vérifiez l’orientation à la limite de la vue. Une vue AppKit ordinaire a une origine en bas à gauche et un axe y croissant vers le haut ; une vue dont isFlipped renvoie true a une origine en haut à gauche et un axe y croissant vers le bas. Une surface de dessin AppKit personnalisée peut adopter cette dernière convention pour correspondre à ce modèle. Laissez la conversion de vue gérer les différences entre les systèmes de coordonnées des vues et évitez d’ajouter un autre retournement ensuite. Documentation d’Apple sur les coordonnées inversées.
Lorsque vous exposez une telle vue via NSViewRepresentable, conservez la gestion des événements AppKit dans la vue représentée et communiquez les résultats au moyen d’un rappel ou d’un coordinateur explicite. Actualisez cette connexion lors des mises à jour et libérez les ressources détenues lors du démontage. SwiftUI contrôle la disposition de la vue représentée ; ne définissez donc pas son frame ou son bounds pour compenser des erreurs de mappage. Documentation d’Apple sur le cycle de vie des représentables.
Dans l’exemple SwiftUI pur, attachez l’interaction au canevas. Si une surface AppKit le remplace, confiez à cette surface la gestion de la zone d’interaction. La conversion des coordonnées doit traduire un événement fourni par son propriétaire existant ; elle ne doit pas nécessiter d’en transmettre une deuxième copie à un autre gestionnaire de sélection concurrent.
Comment le mappage doit-il évoluer pour un canevas plus grand ?
L’exemple prend volontairement en charge le zoom uniforme positif et la translation. Si vous ajoutez une rotation, une mise à l’échelle non uniforme ou une autre origine de dessin, remplacez l’inverse simple par l’inverse de la transformation de dessin complète. Rejetez les mappages non inversibles avant l’interaction et conservez des vérifications aller-retour avec des points connus.
Pour permettre ultérieurement le déplacement des éléments, enregistrez l’élément et le décalage de saisie dans l’espace du modèle au début du geste. Mettez à jour ce même élément pendant tout le geste ; choisissez explicitement si le mappage de la zone d’affichage reste fixe ou change pendant le déplacement. Répéter le test de collision pour choisir un nouvel élément à chaque mouvement modifierait l’élément manipulé lorsque des formes se chevauchent.
Utilisez un seul espace de coordonnées pour la zone d’affichage, un seul mappage de dessin et un seul inverse correspondant. Dès qu’ils concordent, le survol et la sélection peuvent s’appuyer sur une géométrie stable du modèle tandis que le zoom et le panoramique en modifient l’apparence.
Sources
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- Structures et classes — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple