Per selezionare forme in una canvas SwiftUI su Mac, leggi il puntatore nello spazio di coordinate della vista canvas, poi inverti la trasformazione usata per il disegno. Con zoom e spostamento, sottrai l’offset dello spostamento e dividi per lo zoom prima di verificare le forme.
Questo tutorial crea una piccola canvas desktop contenente due rettangoli sintetici. Evidenzierai il rettangolo sotto il puntatore e selezionerai un rettangolo quando termina un gesto del puntatore, anche dopo aver modificato lo zoom o lo spostamento. Tutti i dati di disegno restano in memoria.
Quale spazio di coordinate deve ricevere gli eventi del puntatore?
Tieni distinti tre spazi: il sistema di coordinate in cui sono memorizzate le forme, il sistema di coordinate locale della vista canvas e le eventuali coordinate della finestra fornite da AppKit. Un punto è utile solo se sai a quale spazio appartiene.
SwiftUI onContinuousHover(coordinateSpace:perform:) fornisce le posizioni del puntatore nello spazio di coordinate richiesto; quello predefinito è .local. Fornisce .active(location) mentre il puntatore si trova all’interno della vista e .ended quando esce. Collegalo direttamente alla canvas, così che il suo spazio locale corrisponda all’area di disegno. Documentazione di Apple sul rilevamento continuo del passaggio del puntatore.
Uno spazio di coordinate con nome è utile quando un antenato è intenzionalmente proprietario dell’area di interazione. In tal caso, sia i gestori del passaggio del puntatore sia quelli dei gesti devono richiedere quello stesso spazio con nome. Apple definisce .local, .global e .named(_:) come spazi di coordinate del layout; assegnare un nome a uno spazio non definisce il modello di disegno dell’applicazione. Documentazione di Apple sugli spazi di coordinate.
Questa distinzione è importante perché Canvas esegue il disegno in modalità immediata. Apple documenta esplicitamente che i singoli elementi disegnati, inclusi i simboli, non ricevono interattività propria. L’approccio supportato qui consiste nel gestire l’interazione sulla canvas contenitore ed eseguire personalmente il rilevamento delle collisioni nel modello. Documentazione di Apple su Canvas.
Come si inverte la trasformazione di disegno?
Scegli una convenzione di disegno esplicita. In questo esempio, le coordinate del modello aumentano verso destra e verso il basso. Lo zoom scala le unità del modello e lo spostamento è un offset misurato in punti locali della canvas. La mappatura diretta e la sua inversa sono:
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
Queste equazioni derivano dalla mappatura scelta. Per disegnarla, trasla il contesto grafico dello spostamento e poi applica la scala dello zoom. Apple documenta che questi metodi aggiornano la trasformazione del contesto e influiscono sulle successive operazioni di disegno. Invertire il loro ordine cambia la mappatura, quindi abbina l’inversa all’esatta sequenza di disegno. Documentazione sulla traslazione, documentazione sulla scala.
Per una verifica illustrativa, usa lo zoom 2, lo spostamento (40, 30) e una posizione locale del puntatore (180, 110). Sottraendo lo spostamento si ottiene (140, 80); dividendo per lo zoom si ottiene la posizione nel modello (70, 40). Disegnando quel punto del modello lo si riporta a (180, 110). Un rettangolo la cui cornice nel modello contiene (70, 40) dovrebbe quindi essere rilevato.
Racchiudi questo calcolo in un unico tipo condiviso. Il renderer e il codice di interazione dovrebbero usare la stessa mappatura invece di mantenere formule separate per gli offset. L’esempio accetta solo valori di zoom finiti e positivi; lo zero non è invertibile.
Crea un esempio completo di passaggio del puntatore e selezione
Usa un progetto di app SwiftUI per macOS con una destinazione di distribuzione pari a macOS 14 o successiva. Questa destinazione supporta l’overload CoordinateSpaceProtocol usato dal modificatore per il passaggio del puntatore. Aggiungi il seguente file del modello, poi sostituisci la dichiarazione dell’app generata dal progetto con il secondo file. Mantieni esattamente una dichiarazione @main nella destinazione. Disponibilità e firma dell’API per il passaggio del puntatore.
Definisci la geometria del modello e la relativa mappatura
Crea CanvasGeometry.swift con il seguente contenuto completo. Le cornici dei rettangoli restano nelle coordinate del modello per tutta l’interazione.
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 geometria e la mappatura sono strutture Swift. La loro semantica di valore consente di passare facilmente i valori correnti al codice di disegno e di rilevamento delle collisioni senza introdurre un altro oggetto mutabile condiviso. La guida al linguaggio Swift documenta le strutture come tipi valore. Strutture e classi.
Disegna e interagisci con la stessa area visibile
Crea PointerCanvasApp.swift, sostituendo la dichiarazione dell’app generata. Questo file completo usa i tipi di 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)
}
}
La canvas usa un’area di interazione rettangolare, che include lo spazio vuoto tra le forme. contentShape(Rectangle()) definisce la forma usata per il rilevamento delle interazioni della vista; il test separato frame.contains determina quale elemento sintetico si trova sotto il puntatore. Documentazione di Apple sulle forme del contenuto.
La selezione usa un DragGesture con distanza minima pari a zero e legge location dell’evento finale. Il comportamento definito consiste nel selezionare il rettangolo sotto la posizione di rilascio, anche se il puntatore si è mosso mentre era premuto. Si tratta di un selettore basato sulla posizione di rilascio, non di un riconoscitore rigoroso dei clic. Apple documenta la soglia di distanza e la configurazione dello spazio di coordinate del gesto e identifica location come la posizione corrente dell’evento. DragGesture, DragGesture.Value.
Gli elementi vengono disegnati nell’ordine dell’array e verificati in ordine inverso. Così il rettangolo 2 prevale nelle zone di sovrapposizione, coerentemente con la sovrapposizione visiva di questo esempio. Un clic nello spazio vuoto cancella intenzionalmente la selezione. Le cornici delle forme e il contenuto degli array non cambiano.
La vista possiede lo stato del puntatore e della selezione, mentre il renderer lo legge. Apple consiglia di dichiarare lo stato privato nella vista di livello più alto che ne ha bisogno e di passare binding quando le viste discendenti necessitano dell’accesso in scrittura. Se in seguito un elemento padre diventa proprietario della raccolta di elementi o della selezione, trasferisci lì la proprietà e passa a questa vista i valori o i binding necessari. Documentazione di Apple sullo stato.
Come puoi verificare la mappatura prima di aggiungere altri gesti?
Parti da punti noti del modello e calcola le posizioni locali previste. Questi punti di controllo usano lo zoom e lo spostamento iniziali dell’esempio:
| Punto del modello | Punto locale previsto sulla canvas | Risultato previsto |
|---|---|---|
(70, 40) |
(180, 110) |
Rettangolo 1 |
(140, 90) |
(320, 210) |
Rettangolo 2, nella zona di sovrapposizione |
(10, 10) |
(60, 50) |
Nessun rettangolo |
Le posizioni locali sono misurate rispetto alla canvas stessa; la barra degli strumenti e il padding esterno non appartengono a quello spazio. Muoviti su entrambi i rettangoli, rilascia il puntatore sulla zona di sovrapposizione, poi prova Zoom out e Pan right. L’evidenziazione dovrebbe seguire la geometria visibile a ogni impostazione.
Il calcolo illustrativo dell’inversa e 12 verifiche di andata e ritorno tra mappatura diretta e inversa sono stati eseguiti con l’aritmetica di Python. Nell’ambiente di ricerca non erano disponibili un compilatore Swift e il runtime UI di macOS, quindi l’applicazione Swift è stata esaminata ma non compilata né testata in modo interattivo.
Se una verifica non riesce, usa il sintomo per restringere la causa:
- Un offset costante suggerisce che il gestore dell’input appartiene a una vista diversa oppure che è stato omesso lo spostamento.
- Un errore che aumenta con la distanza suggerisce che manca una conversione di scala o che l’ordine delle trasformazioni non corrisponde.
- Un risultato ribaltato verticalmente suggerisce un’ulteriore inversione dell’asse y o un punto AppKit non convertito.
- Una selezione errata solo nelle zone di sovrapposizione suggerisce un ordine incoerente tra disegno e rilevamento delle collisioni.
Memorizza il punto locale grezzo del passaggio del puntatore, come fa l’esempio, e ricava il punto del modello dalla mappatura corrente. In questo modo eviti di conservare una posizione del puntatore nel modello calcolata con uno zoom precedente. La selezione usa la posizione propria dell’evento del gesto, quindi non dipende da una precedente callback del passaggio del puntatore.
Cosa cambia quando l’evento proviene da AppKit?
Se una vista AppKit personalizzata esistente gestisce già l’interazione col mouse, converti l’evento in quel punto prima di applicare l’inversa del disegno. NSEvent.locationInWindow usa normalmente le coordinate di base della finestra associata. Apple osserva che un evento con una finestra nil può invece contenere coordinate dello schermo, quindi verifica prima che l’evento appartenga alla finestra della vista ricevente. Documentazione di Apple sulla posizione dell’evento.
All’interno di quella NSView ricevente, l’espressione convert(event.locationInWindow, from: nil) converte un punto della finestra nelle coordinate locali della vista. Apple documenta nil come origine delle coordinate della finestra per questa conversione. Solo a quel punto applica l’inversa della trasformazione usata dal renderer della vista. Documentazione di Apple sulla conversione dei punti.
Verifica l’orientamento al confine della vista. Una normale vista AppKit ha l’origine in basso a sinistra e l’asse y crescente verso l’alto; una vista il cui isFlipped restituisce true ha l’origine in alto a sinistra e l’asse y crescente verso il basso. Una superficie di disegno AppKit personalizzata può adottare quest’ultima convenzione per corrispondere al modello. Lascia che la conversione della vista gestisca le differenze tra sistemi di coordinate delle viste ed evita di aggiungere un’altra inversione in seguito. Documentazione di Apple sulle coordinate invertite.
Quando esponi una vista del genere tramite NSViewRepresentable, mantieni la gestione degli eventi AppKit nella vista rappresentata e comunica i risultati tramite una callback o un coordinatore esplicito. Aggiorna il collegamento durante gli aggiornamenti e libera le risorse di cui sei proprietario durante la fase di teardown. SwiftUI controlla il layout della vista rappresentata, quindi non impostare il suo frame o bounds per compensare errori di mappatura. Documentazione di Apple sul ciclo di vita delle viste rappresentabili.
Per l’esempio SwiftUI puro, mantieni l’interazione collegata alla canvas. Se la sostituisci con una superficie AppKit, assegna a quella superficie la proprietà dell’area di interazione. La conversione delle coordinate dovrebbe tradurre un evento fornito dal suo proprietario esistente; non dovrebbe richiedere l’inoltro di una seconda copia a un altro gestore di selezione concorrente.
Come dovrebbe evolvere la mappatura per una canvas più grande?
L’esempio supporta intenzionalmente solo uno zoom uniforme positivo e una traslazione. Se aggiungi rotazione, scala non uniforme o un’origine di disegno diversa, sostituisci la semplice inversa con l’inversa della trasformazione di disegno completa. Rifiuta le mappature non invertibili prima dell’interazione e conserva le verifiche dirette e inverse con punti noti.
Per trascinare elementi in futuro, registra l’elemento e l’offset di presa nel modello all’inizio del gesto. Aggiorna lo stesso elemento per tutta la durata del gesto; scegli esplicitamente se la mappatura dell’area visibile resta fissa o cambia durante il trascinamento. Ripetere il rilevamento delle collisioni per scegliere un nuovo elemento a ogni movimento cambierebbe l’elemento controllato dall’interazione ogni volta che le forme si sovrappongono.
Usa un solo spazio di coordinate per l’area visibile, una sola mappatura di disegno e la relativa inversa. Quando questi elementi concordano, il passaggio del puntatore e la selezione possono operare su una geometria stabile del modello mentre zoom e spostamento ne cambiano l’aspetto.
Fonti
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- Strutture e classi — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple