Als je vormen op een Mac SwiftUI-canvas wilt selecteren, lees je de aanwijzer uit in de coördinatenruimte van de canvasweergave en maak je daarna de voor het tekenen gebruikte transformatie ongedaan. Trek bij zoomen en pannen de pan-offset af en deel door de zoomfactor voordat je de vormen test.

In deze tutorial bouwen we een klein canvas voor desktop met twee synthetische rechthoeken. Je markeert de rechthoek onder de aanwijzer en selecteert een rechthoek zodra een aanwijzergebaar eindigt, ook nadat je de zoomfactor of pan hebt gewijzigd. Alle tekengegevens blijven in het geheugen.

Welke coördinatenruimte moet aanwijzergebeurtenissen ontvangen?

Houd drie ruimtes uit elkaar: het coördinatenstelsel waarin je vormen zijn opgeslagen, het lokale coördinatenstelsel van de canvasweergave en eventuele venstercoördinaten van AppKit. Een punt is alleen bruikbaar als je weet bij welke ruimte het hoort.

SwiftUI’s onContinuousHover(coordinateSpace:perform:) levert aanwijzerposities in de aangevraagde coördinatenruimte; de standaardwaarde is .local. De modifier levert .active(location) zolang de aanwijzer zich binnen de weergave bevindt en .ended wanneer deze de weergave verlaat. Koppel de modifier rechtstreeks aan het canvas, zodat de lokale ruimte overeenkomt met het tekenvenster. Apple-documentatie over continu zweven.

Een benoemde coördinatenruimte is handig wanneer een bovenliggende weergave bewust het interactiegebied beheert. Vraag dan in zowel de zweef- als de gebaarhandlers dezelfde benoemde ruimte aan. Apple definieert .local, .global en .named(_:) als coördinatenruimtes voor de lay-out; een ruimte benoemen definieert niet het tekenmodel van de applicatie. Apple-documentatie over coördinatenruimtes.

Dat onderscheid is van belang omdat Canvas directe-modustekeningen uitvoert. Apple documenteert uitdrukkelijk dat afzonderlijk getekende elementen, waaronder symbolen, geen eigen interactiviteit ontvangen. De ondersteunde aanpak hier is om interactie af te handelen op het omvattende canvas en de model-hit-test zelf uit te voeren. Apple-documentatie over Canvas.

Hoe maak je de tekentransformatie ongedaan?

Kies een expliciete tekenconventie. In dit voorbeeld nemen modelcoördinaten naar rechts en naar beneden toe. De zoom schaalt modeleeneenheden en de pan is een offset in canvaslokale punten. De voorwaartse afbeelding en de inverse daarvan zijn:

viewX = modelX * zoom + panX
viewY = modelY * zoom + panY

modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom

Deze vergelijkingen volgen uit de gekozen afbeelding. Om hiermee te tekenen, verplaats je de grafische context met de pan en schaal je die vervolgens met de zoomfactor. Apple documenteert dat deze methoden de transformatie van de context bijwerken en van invloed zijn op daaropvolgende tekenbewerkingen. Als je de volgorde omdraait, verandert de afbeelding; houd de inverse daarom gekoppeld aan de exacte tekenvolgorde. Documentatie over verplaatsen, documentatie over schalen.

Neem voor een illustratieve controle zoomfactor 2, pan (40, 30) en een lokale aanwijzerpositie (180, 110). Na aftrek van de pan krijg je (140, 80); delen door de zoomfactor geeft modelpositie (70, 40). Door dat modelpunt te tekenen, krijg je weer (180, 110). Een rechthoek waarvan het modelkader (70, 40) bevat, moet dus worden geraakt.

Voer deze berekening uit in één gedeeld type. Zowel de renderer als de interactiecode moeten dezelfde afbeelding gebruiken in plaats van afzonderlijke offsetformules bij te houden. Het voorbeeld accepteert alleen eindige, positieve zoomfactoren; nul kan niet worden geïnverteerd.

Bouw een compleet voorbeeld met zweven en selecteren

Gebruik een macOS SwiftUI-appproject met macOS 14 of hoger als implementatiedoel. Die versie ondersteunt de CoordinateSpaceProtocol-overload die door de zweefmodifier wordt gebruikt. Voeg het volgende modelbestand toe en vervang daarna de door het project gegenereerde app-declaratie door het tweede bestand. Houd precies één @main-declaratie in het doel. Beschikbaarheid en signatuur van de hover-API.

Definieer modelgeometrie en de afbeelding daarvan

Maak CanvasGeometry.swift met de volgende volledige inhoud. Rechthoekkaders blijven tijdens alle interacties in modelcoördinaten.

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
}

De geometrie en afbeelding zijn Swift-structuren. Dankzij hun waardesemantiek kun je de huidige waarden eenvoudig doorgeven aan teken- en hit-testcode, zonder een extra gedeeld veranderlijk object te introduceren. De taalgids van Swift beschrijft structuren als waardetypen. Structuren en klassen.

Teken in hetzelfde venster waarin je interactie afhandelt

Maak PointerCanvasApp.swift en vervang daarmee de gegenereerde app-declaratie. Dit volledige bestand gebruikt de typen uit 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)
    }
}

Het canvas gebruikt een rechthoekig interactiegebied, inclusief de lege ruimte tussen vormen. contentShape(Rectangle()) bepaalt de hit-testvorm van de weergave; de afzonderlijke test frame.contains bepaalt welk synthetisch item zich onder de aanwijzer bevindt. Apple-documentatie over content shapes.

Selectie gebruikt een DragGesture met een minimale afstand van nul en leest de location van de eindgebeurtenis uit. Het gedefinieerde gedrag is om de rechthoek onder de loslaatpositie te selecteren, ook als de aanwijzer tijdens het indrukken is verplaatst. Dit is een selector op basis van de loslaatpositie, geen strikte klikherkenner. Apple documenteert de afstandsdrempel en de configuratie van de coördinatenruimte van het gebaar, en geeft aan dat location de huidige gebeurtenispositie is. DragGesture, DragGesture.Value.

De items worden in arrayvolgorde getekend en in omgekeerde volgorde getest. Daardoor wint rechthoek 2 waar de twee elkaar overlappen, overeenkomstig de visuele stapeling in dit voorbeeld. Een klik op lege ruimte maakt de selectie bewust ongedaan. De vormkaders en array-inhoud veranderen nooit.

De weergave beheert de aanwijzer- en selectiestatus, terwijl de renderer deze uitleest. Apple raadt aan om private status te declareren in de hoogste weergave die deze nodig heeft, en bindings door te geven wanneer onderliggende weergaven schrijfrechten nodig hebben. Als een bovenliggende weergave later de itemverzameling of selectie beheert, verplaats je het beheer daarnaartoe en geef je de benodigde waarden of bindings aan deze weergave door. Apple-documentatie over status.

Hoe kun je de afbeelding controleren voordat je meer gebaren toevoegt?

Begin met bekende modelpunten en bereken hun verwachte lokale posities. Deze controlepunten gebruiken de beginwaarden voor zoom en pan uit het voorbeeld:

Modelpunt Verwacht canvaslokaal punt Verwacht resultaat
(70, 40) (180, 110) Rechthoek 1
(140, 90) (320, 210) Rechthoek 2, in het overlappende gebied
(10, 10) (60, 50) Geen rechthoek

Meet de lokale posities vanaf het canvas zelf; de werkbalk en buitenste opvulling vallen buiten die ruimte. Beweeg over beide rechthoeken, laat los boven hun overlappende gebied en probeer daarna Zoom out en Pan right. De markering moet bij elke instelling de zichtbare geometrie volgen.

De illustratieve inverseberekening en 12 controles van de heen-en-terugtransformatie zijn uitgevoerd met Python-rekenwerk. In de onderzoeksomgeving waren geen Swift-compiler en macOS UI-runtime beschikbaar. De Swift-applicatie is daarom geïnspecteerd, maar niet gecompileerd of interactief getest.

Als een controle mislukt, gebruik dan het symptoom om de oorzaak te vinden:

  • Een constante offset wijst erop dat de invoerhandler bij een andere weergave hoort of dat de pan is weggelaten.
  • Een fout die groter wordt met de afstand wijst op een ontbrekende schaalconversie of een niet-overeenkomende transformvolgorde.
  • Een verticaal gespiegeld resultaat wijst op een extra y-asomkering of een niet-geconverteerd AppKit-punt.
  • Een onjuiste selectie, uitsluitend in overlappende gebieden, wijst op een verschil tussen de tekenvolgorde en de hit-testvolgorde.

Sla het ruwe lokale zweefpunt op, zoals het voorbeeld doet, en leid het modelpunt af uit de huidige afbeelding. Zo voorkom je dat je een modelruimte-zweefpositie bewaart die op basis van een eerdere zoomfactor is berekend. Selectie gebruikt de eigen locatie van de gebaargebeurtenis en is daardoor niet afhankelijk van een eerdere zweefcallback.

Wat verandert er wanneer de gebeurtenis uit AppKit komt?

Als een bestaande aangepaste AppKit-weergave de muisinteractie al beheert, converteer je de gebeurtenis bij die grens voordat je de inverse van de tekeningstransformatie toepast. NSEvent.locationInWindow gebruikt normaal gesproken de basiscoördinaten van het bijbehorende venster. Apple merkt op dat een gebeurtenis met een venster nil in plaats daarvan schermcoördinaten kan bevatten. Controleer dus eerst of de gebeurtenis bij het venster van de ontvangende weergave hoort. Apple-documentatie over gebeurtenislocaties.

In de ontvangende NSView zet de expressie convert(event.locationInWindow, from: nil) een vensterpunt om naar lokale coördinaten van de weergave. Apple documenteert nil als de bron voor venstercoördinaten bij deze conversie. Pas pas daarna de inverse toe van de transformatie die de renderer van die weergave gebruikt. Apple-documentatie over puntconversies.

Controleer de oriëntatie bij de grens van de weergave. Een gewone AppKit-weergave heeft een oorsprong linksonder, met een y-as die naar boven toeneemt. Een weergave waarvan isFlipped true teruggeeft, heeft een oorsprong linksboven, met een y-as die naar beneden toeneemt. Een aangepast AppKit-tekenoppervlak kan die laatste conventie gebruiken zodat deze overeenkomt met het model. Laat de weergaveconversie verschillen tussen coördinatenstelsels van weergaven afhandelen en voeg daarna niet nog een omkering toe. Apple-documentatie over omgekeerde coördinaten.

Wanneer je zo’n weergave beschikbaar maakt via NSViewRepresentable, houd je de AppKit-gebeurtenisafhandeling in de weergegeven weergave en geef je resultaten door via een expliciete callback of coordinator. Werk die verbinding tijdens updates bij en ruim eigen resources op bij het afbreken. SwiftUI beheert de lay-out van de weergegeven weergave; stel daarom frame of bounds ervan niet in om afbeeldingsfouten te compenseren. Apple-documentatie over de levenscyclus van representables.

Houd de interactie voor het zuivere SwiftUI-voorbeeld aan het canvas gekoppeld. Als een AppKit-oppervlak het vervangt, laat je dat oppervlak het interactiegebied beheren. Coördinatenconversie vertaalt een gebeurtenis die door de bestaande eigenaar wordt aangeleverd; daarvoor hoeft geen tweede kopie naar een andere, concurrerende selectiehandler te worden doorgestuurd.

Hoe moet de afbeelding zich ontwikkelen voor een groter canvas?

Het voorbeeld ondersteunt bewust alleen gelijkmatige, positieve zoom en verplaatsing. Als je rotatie, niet-uniforme schaling of een andere tekenoorsprong toevoegt, vervang je de eenvoudige inverse door de inverse van de volledige tekentransformatie. Wijs niet-inverteerbare afbeeldingen af voordat interactie plaatsvindt en behoud controles met bekende punten voor zowel de voorwaartse afbeelding als de inverse.

Leg voor het later slepen van items bij de start van het gebaar het item en de aangrijpoffset in modelruimte vast. Werk tijdens het gebaar datzelfde item bij en kies expliciet of de afbeeldingsmapping van het venster tijdens het slepen vast blijft of verandert. Als je bij elke verplaatsing opnieuw een hit-test uitvoert om een nieuw item te kiezen, verandert het eigenaarschap van de interactie zodra vormen elkaar overlappen.

Gebruik één coördinatenruimte voor het venster, één tekenafbeelding en één bijbehorende inverse. Zodra die overeenkomen, kunnen zweven en selecteren met stabiele modelgeometrie werken, terwijl zoom en pan veranderen hoe die geometrie wordt weergegeven.

Bronnen