Combinez DragGesture, RotateGesture et MagnifyGesture avec simultaneously(with:), puis affichez leurs valeurs au moyen d’un pipeline de transformation unique. Pour un canevas Mac, gardez la surface de saisie immobile et stockez séparément la modification temporaire de chaque geste et son résultat validé.
Ce tutoriel construit un petit visualiseur de formes dans votre propre app macOS : faites glisser avec un clic maintenu pour déplacer la forme, pincez pour zoomer et utilisez un geste de rotation sur le trackpad pour la faire tourner. Il utilise uniquement un rectangle et un cercle en mémoire. La composition permet le chevauchement de gestes reconnus ; les entrées qui se chevauchent réellement dépendent des événements transmis par l’appareil.
Que signifie « simultané » sur Mac ?
L’API simultaneously(with:) d’Apple combine deux gestes en un autre geste, ce qui permet de combiner le résultat avec un troisième. Elle fournit ainsi la composition nécessaire au panoramique, à la rotation et au zoom sans exiger un appui prolongé ni une autre action préalable. La documentation d’Apple sur les gestes simultanés décrit cette combinaison.
Définissez le sens du panoramique avant d’écrire le code. Ici, il s’agit de faire glisser la souris avec son bouton principal enfoncé, y compris en cliquant et faisant glisser sur un trackpad. Le glissement de deux doigts sur un trackpad produit normalement une entrée de défilement. Le guide archivé d’Apple sur les événements distingue explicitement ce flux de défilement des gestes de zoom et de rotation ; la documentation actuelle d’AppKit expose toujours le défilement via scrollWheel(with:). Ajouter un geste de glissement ne transforme pas le défilement en glissement. Gestion des événements du trackpad, scrollWheel(with:)
Utilisez ce tableau de décision pour choisir la couche d’entrée de votre canevas :
| Interaction souhaitée | Point de départ | Limite à conserver |
|---|---|---|
| Panoramique par glisser avec clic, zoom par pincement, rotation sur trackpad | L’exemple SwiftUI ci-dessous | La composition permet le chevauchement des flux de gestes reconnus. |
| Panoramique par défilement à deux doigts | Un canevas AppKit intégré à SwiftUI | Le défilement reste un flux d’événements de défilement. |
| Transformation à trois composantes garantie par un seul mouvement de doigts | Une interaction personnalisée spécifiée séparément | La composition simultanée ne garantit pas à elle seule que le matériel ou macOS émettra chaque composante. |
L’exemple nécessite macOS 14 ou ultérieur, version à partir de laquelle MagnifyGesture et RotateGesture sont tous deux disponibles. Il utilise leurs initialiseurs de base plutôt que les API de configuration du type d’entrée plus récentes. MagnifyGesture, RotateGesture
Comment organiser l’état de transformation ?
Attribuez à chaque composante une valeur validée et une valeur de geste en cours. La translation et la rotation se combinent par addition ; l’échelle, par multiplication. Les équations suivantes définissent le comportement de l’exemple ; SwiftUI ne les impose pas :
translation affichée = translation validée + translation du glissement en cours
rotation affichée = rotation validée + rotation en cours
échelle affichée = clamp(échelle validée × grossissement en cours)
Utilisez @GestureState pour les valeurs en cours. Apple indique que cet état est réinitialisé lorsqu’un geste devient inactif. Validez chaque composante dans son propre rappel onEnded, qui ne s’exécute qu’après la réussite du geste. L’annulation a ainsi un sens clair : abandonner l’aperçu inachevé de cette composante et conserver sa valeur précédemment validée. GestureState, onEnded(_:)
Conservez ces rappels sur les gestes individuels avant de les composer. Chaque rappel reçoit alors sa propre valeur typée et ne modifie que la propriété dont il est responsable. Aucun instantané commun de la transformation de départ ne risque d’être écrasé par un geste qui commence plus tard.
La translation du glissement exige une attention particulière : sa valeur correspond au déplacement total depuis le début du geste. Ajouter ce total à la position validée à chaque mise à jour compterait plusieurs fois les déplacements précédents. Affectez le total à l’aperçu en cours, puis ajoutez une seule fois le total final lorsque le geste se termine. DragGesture.Value.translation
Créer l’exemple SwiftUI complet
Créez une app SwiftUI pour macOS avec une cible de déploiement macOS 14 ou ultérieure. Remplacez le point d’entrée de l’app et la vue de contenu générés par ce fichier unique ; conservez exactement une déclaration @main dans la cible. Aucun élément graphique, package ou donnée externe n’est nécessaire.
Le canevas gris gère le geste. Sa forme enfant reçoit les transformations visuelles : déplacer ou faire pivoter la forme ne transforme donc pas la surface de saisie du geste. Dans cet exemple, le zoom et la rotation s’effectuent délibérément autour du centre de la forme.
import SwiftUI
@main
struct TransformCanvasApp: App {
var body: some Scene {
WindowGroup {
TransformCanvas()
.frame(minWidth: 400, minHeight: 300)
}
.defaultSize(width: 720, height: 500)
}
}
struct TransformCanvas: View {
@State private var offset = CGSize.zero
@State private var angle = Angle.zero
@State private var scale: CGFloat = 1
@GestureState private var liveOffset = CGSize.zero
@GestureState private var liveAngle = Angle.zero
@GestureState private var liveScale: CGFloat = 1
private func boundedScale(_ value: CGFloat) -> CGFloat {
min(max(value, 0.25), 4)
}
private var pan: some Gesture {
DragGesture(minimumDistance: 3, coordinateSpace: .local)
.updating($liveOffset) { value, state, _ in
state = value.translation
}
.onEnded { value in
offset = CGSize(
width: offset.width + value.translation.width,
height: offset.height + value.translation.height
)
}
}
private var rotation: some Gesture {
RotateGesture()
.updating($liveAngle) { value, state, _ in
state = value.rotation
}
.onEnded { value in
angle = angle + value.rotation
}
}
private var zoom: some Gesture {
MagnifyGesture()
.updating($liveScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale = boundedScale(scale * value.magnification)
}
}
private var manipulation: some Gesture {
pan.simultaneously(with: rotation)
.simultaneously(with: zoom)
}
var body: some View {
ZStack {
Color.gray.opacity(0.12)
RoundedRectangle(cornerRadius: 18)
.fill(Color.blue)
.overlay(alignment: .topLeading) {
Circle()
.fill(Color.white)
.frame(width: 24, height: 24)
.padding(16)
}
.frame(width: 180, height: 120)
.scaleEffect(
boundedScale(scale * liveScale),
anchor: .center
)
.rotationEffect(angle + liveAngle, anchor: .center)
.offset(CGSize(
width: offset.width + liveOffset.width,
height: offset.height + liveOffset.height
))
.allowsHitTesting(false)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.clipped()
.gesture(manipulation)
}
}
Apple documente les types de gestes de glissement, de zoom et de rotation, ainsi que la manière d’attacher des gestes aux vues. L’exemple combine ces API avec une politique explicite de gestion de l’état et des coordonnées. SwiftUI n’était pas disponible dans l’environnement de recherche ; le programme a donc été vérifié à l’aide de la documentation, mais n’a pas été compilé ni exécuté ici. DragGesture, MagnifyGesture, RotateGesture
Pourquoi appliquer l’échelle, puis la rotation, puis le décalage ?
La transformation voulue consiste à mettre la forme à l’échelle autour de son centre, à la faire pivoter autour du même centre, puis à la déplacer dans les coordonnées du canevas. Appliquer le décalage en dernier maintient le panoramique en dehors de la mise à l’échelle et de la rotation de la forme. Déplacer le pointeur horizontalement doit déplacer la forme horizontalement, même après sa rotation.
Cet ordre est un choix de conception dicté par le pipeline de transformation de l’exemple. Apple indique que scaleEffect met le contenu rendu à l’échelle autour d’un point d’ancrage, que rotationEffect fait pivoter le contenu rendu et que offset déplace le contenu affiché. Ces effets visuels ne transforment pas le canevas immobile en conteneur de disposition transformé. scaleEffect(_:anchor:), rotationEffect(_:anchor:), offset(_:)
Les limites de 0,25 et 4 sont des paramètres d’application illustratifs. La même fonction de limitation s’applique à l’aperçu et à la validation, de sorte que le relâchement du geste conserve l’échelle affichée. min et max de Swift sélectionnent les valeurs minimale et maximale servant à définir ces limites. min(::), max(::)
Vérifier l’interaction à l’aide de résultats concrets
Commencez par tester chaque composante séparément, puis essayez de les faire se chevaucher avec les appareils d’entrée disponibles. Le cercle blanc rend la rotation visible, même lorsque le rectangle est petit.
- Cliquez sur le canevas et faites glisser, relâchez, puis recommencez. Le deuxième glissement doit partir de la position validée par le premier.
- Pincez pour agrandir la forme, relâchez, puis pincez à nouveau. Chaque geste doit multiplier l’échelle conservée.
- Faites pivoter la forme, relâchez, puis recommencez. L’angle doit s’accumuler.
- Après avoir zoomé et fait pivoter la forme, faites-la glisser horizontalement. Elle doit toujours se déplacer le long de l’axe horizontal du canevas.
- Lorsque les appareils transmettent des entrées qui se chevauchent, terminez une composante tout en continuant l’autre. La composante restante doit continuer à mettre à jour sa propre propriété.
- Interrompez un geste en cours, par exemple en changeant d’application. Tout geste qui devient inactif sans se terminer avec succès doit perdre son aperçu en cours, afin qu’aucun état temporaire ne reste bloqué.
À titre de vérification arithmétique, supposons que l’échelle conservée soit de 1,5 et qu’un pincement renvoie 1,2. Le résultat affiché est 1,8. Si l’angle conservé est de 30 degrés et la rotation en cours de 15 degrés, l’angle affiché est de 45 degrés. Une translation de glissement de 40 points reste un décalage de 40 points dans le canevas, quelle que soit l’échelle ou la rotation.
Cet exemple valide les composantes indépendamment. Si la rotation se termine avec succès et qu’un glissement toujours actif est ensuite annulé, la rotation terminée est conservée tandis que le glissement inachevé est annulé. Un éditeur qui exige une opération atomique regroupant toutes les composantes nécessite un modèle de session d’interaction supplémentaire, avec une politique explicite de validation et d’annulation.
La forme peut également être déplacée en dehors de la zone visible du canevas, car sa translation n’est volontairement pas limitée. Le canevas reste disponible pour le glissement, ce qui permet de la ramener dans l’autre sens. Un visualiseur abouti peut ajouter une commande de réinitialisation distincte ou contraindre la translation en fonction des limites de son contenu.
Quand faut-il plutôt utiliser un canevas AppKit ?
Choisissez une couche d’entrée AppKit lorsque la tâche exige précisément un panoramique par défilement à deux doigts ou un contrôle direct de l’inertie du défilement. Intégrez une vue AppKit à l’aide de NSViewRepresentable, qu’Apple fournit pour créer, mettre à jour et démanteler des vues AppKit dans SwiftUI. NSViewRepresentable
Attribuez un seul responsable à chaque flux d’entrée. Dans ce canevas AppKit, gérez le défilement avec scrollWheel(with:) et utilisez des reconnaisseurs pour le zoom et la rotation. Transmettez les modifications résultantes au même modèle de translation, d’angle et d’échelle. Évitez d’attacher un autre gestionnaire SwiftUI qui appliquerait aussi les mêmes entrées à ce modèle.
Pour que les reconnaisseurs AppKit puissent coexister, implémentez gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) et ne renvoyez true que pour les paires de reconnaisseurs prévues dans votre canevas. Apple indique que la méthode du délégué autorise la reconnaissance simultanée ; elle ne permet pas de créer un flux d’entrée manquant. Méthode du délégué pour la reconnaissance simultanée
Conservez les conventions des valeurs lors du pontage. Le reconnaisseur AppKit de grossissement renvoie une valeur à laquelle il faut ajouter 1 pour obtenir un facteur d’échelle, tandis que le reconnaisseur de rotation expose des radians. Ne copiez pas telles quelles les affectations de valeurs SwiftUI. NSMagnificationGestureRecognizer.magnification, NSRotationGestureRecognizer.rotation
Pour le défilement, Apple désigne scrollingDeltaX comme propriété à privilégier pour le décalage horizontal et précise qu’il peut être nécessaire d’ajuster les décalages non précis. Sa documentation sur l’inertie décrit les événements de défilement qui continuent après l’arrêt du geste physique. Décidez si le canevas accepte cette poursuite, puis transmettez chaque événement accepté une seule fois. scrollingDeltaX, momentumPhase
Le pont doit modifier la transformation du contenu de votre canevas, tandis que SwiftUI conserve la responsabilité de la disposition de la vue représentée. Apple déconseille explicitement de définir directement le cadre ou les limites de cette vue. Utilisez le coordinateur du type représentable pour transmettre à SwiftUI les changements de délégué et d’action de cible. NSViewRepresentable
Pour le visualiseur à glisser avec un clic, l’implémentation SwiftUI fournit un point de départ complet : une surface de saisie immobile, trois composantes de geste gérées indépendamment et un ordre de transformation défini. Conservez ces limites en le développant, et choisissez AppKit lorsque l’interaction demandée par l’utilisateur nécessite un panoramique par défilement.
Sources
- simultaneously(with:) — Apple
- Gestion des événements du trackpad — Apple, archive de documentation
- scrollWheel(with:) — Apple
- MagnifyGesture — Apple
- RotateGesture — Apple
- GestureState — Apple
- onEnded(_:) — Apple
- translation — Apple
- DragGesture — Apple
- scaleEffect(_:anchor:) — Apple
- rotationEffect(_:anchor:) — Apple
- offset(_:) — Apple
- min(::) — Apple
- max(::) — Apple
- NSViewRepresentable — Apple
- gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) — Apple
- magnification — Apple
- rotation — Apple
- scrollingDeltaX — Apple
- momentumPhase — Apple