Para seleccionar formas en un lienzo de SwiftUI para Mac, lee la posición del puntero en el espacio de coordenadas de la vista del lienzo y luego invierte la transformación usada para dibujar. Con zoom y desplazamiento, resta el desplazamiento de panorámica y divide por el zoom antes de comprobar las formas.

Este tutorial crea un pequeño lienzo de escritorio con dos rectángulos sintéticos. Resaltarás el rectángulo situado bajo el puntero y seleccionarás uno cuando termine un gesto del puntero, incluso después de cambiar el zoom o la panorámica. Todos los datos de dibujo permanecen en memoria.

¿Qué espacio de coordenadas debe recibir los eventos del puntero?

Mantén diferenciados tres espacios: el sistema de coordenadas donde se almacenan las formas, el sistema de coordenadas local de la vista del lienzo y cualquier coordenada de ventana proporcionada por AppKit. Un punto solo es útil si sabes a qué espacio pertenece.

onContinuousHover(coordinateSpace:perform:) de SwiftUI proporciona ubicaciones del puntero en el espacio de coordenadas solicitado; el valor predeterminado es .local. Entrega .active(location) mientras el puntero está dentro de la vista y .ended cuando sale. Adjústalo directamente al lienzo para que su espacio local coincida con la ventana gráfica de dibujo. Documentación de Apple sobre el seguimiento continuo del puntero.

Un espacio de coordenadas con nombre es útil cuando un elemento antecesor es el propietario intencional de la región de interacción. En ese caso, los controladores de desplazamiento y de gestos deben solicitar el mismo espacio con nombre. Apple define .local, .global y .named(_:) como espacios de coordenadas de diseño; asignar un nombre a un espacio no define el modelo de dibujo de la aplicación. Documentación de Apple sobre espacios de coordenadas.

Esta distinción importa porque Canvas realiza dibujo en modo inmediato. Apple documenta explícitamente que los elementos dibujados individuales, incluidos los símbolos, no reciben interactividad propia. El enfoque compatible aquí consiste en gestionar la interacción en el lienzo contenedor y realizar tú mismo la detección de impactos en el modelo. Documentación de Apple sobre Canvas.

¿Cómo se invierte la transformación de dibujo?

Elige una convención de dibujo explícita. En este ejemplo, las coordenadas del modelo aumentan hacia la derecha y hacia abajo. El zoom escala las unidades del modelo y la panorámica es un desplazamiento medido en puntos locales del lienzo. El mapeo directo y su inversa son:

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

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

Estas ecuaciones se derivan del mapeo elegido. Para dibujarlo, traslada el contexto gráfico según la panorámica y luego escálalo según el zoom. Apple documenta que estos métodos actualizan la transformación del contexto y afectan las operaciones de dibujo posteriores. Invertir su orden cambia el mapeo, así que mantén la inversa emparejada con la secuencia de dibujo exacta. Documentación sobre traslación, documentación sobre escalado.

Como comprobación ilustrativa, usa el zoom 2, la panorámica (40, 30) y una posición local del puntero (180, 110). Al restar la panorámica se obtiene (140, 80); al dividir por el zoom se obtiene la posición en el modelo (70, 40). Al dibujar ese punto del modelo, vuelve a (180, 110). Por tanto, debería detectarse el impacto de un rectángulo cuyo marco en el modelo contenga (70, 40).

Mantén este cálculo en un único tipo compartido. El renderizador y el código de interacción deben usar el mismo mapeo, en lugar de mantener fórmulas de desplazamiento separadas. El ejemplo solo acepta valores de zoom finitos y positivos; el cero no se puede invertir.

Crea un ejemplo completo de resaltado y selección

Usa un proyecto de app SwiftUI para macOS con un destino de implementación de macOS 14 o posterior. Ese destino admite la sobrecarga de CoordinateSpaceProtocol que usa el modificador de seguimiento del puntero. Añade el siguiente archivo del modelo y luego reemplaza la declaración de app generada por el proyecto con el segundo archivo. Mantén exactamente una declaración @main en el destino. Disponibilidad y firma de la API de seguimiento del puntero.

Define la geometría del modelo y su mapeo

Crea CanvasGeometry.swift con el siguiente contenido completo. Los marcos de los rectángulos permanecen en coordenadas del modelo durante toda la interacción.

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 geometría y el mapeo son estructuras de Swift. Su semántica de valor facilita pasar los valores actuales al código de dibujo y de detección de impactos sin introducir otro objeto mutable compartido. La guía del lenguaje Swift documenta las estructuras como tipos de valor. Estructuras y clases.

Dibuja e interactúa con la misma ventana gráfica

Crea PointerCanvasApp.swift y reemplaza la declaración de app generada. Este archivo completo usa los tipos 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)
    }
}

El lienzo usa una región de interacción rectangular, incluido el espacio vacío entre las formas. contentShape(Rectangle()) define la forma de detección de impactos de la vista; la prueba independiente frame.contains determina qué elemento sintético está bajo el puntero. Documentación de Apple sobre formas de contenido.

La selección usa un DragGesture con una distancia mínima de cero y lee el location del evento final. Su comportamiento definido consiste en seleccionar el rectángulo bajo la posición donde se suelta el puntero, aunque este se haya movido mientras se mantenía presionado. Es un selector basado en la posición de liberación, no un reconocedor estricto de clics. Apple documenta el umbral de distancia y la configuración del espacio de coordenadas del gesto, e identifica location como la posición actual del evento. DragGesture, DragGesture.Value.

Los elementos se dibujan en el orden del arreglo y se comprueban en orden inverso. Así, el rectángulo 2 prevalece donde los dos se superponen, de acuerdo con el apilamiento visual de este ejemplo. Al hacer clic en el espacio vacío, se borra la selección deliberadamente. Los marcos de las formas y el contenido del arreglo nunca cambian.

La vista es propietaria del estado del puntero y de la selección, mientras que el renderizador lo lee. Apple recomienda declarar el estado privado en la vista de nivel más alto que lo necesite y pasar enlaces si las vistas descendientes requieren acceso de escritura. Si más adelante un elemento padre es propietario de la colección de elementos o de la selección, traslada allí esa responsabilidad y pasa a esta vista los valores o enlaces necesarios. Documentación de Apple sobre el estado.

¿Cómo puedes comprobar el mapeo antes de añadir más gestos?

Empieza con puntos conocidos del modelo y calcula sus posiciones locales esperadas. Estos puntos de control usan el zoom y la panorámica iniciales del ejemplo:

Punto del modelo Punto local esperado del lienzo Resultado esperado
(70, 40) (180, 110) Rectángulo 1
(140, 90) (320, 210) Rectángulo 2, en la zona superpuesta
(10, 10) (60, 50) Ningún rectángulo

Las posiciones locales se miden desde el propio lienzo; la barra de herramientas y el relleno exterior quedan fuera de ese espacio. Mueve el puntero por ambos rectángulos, suéltalo sobre la zona superpuesta y luego prueba Reducir zoom y Desplazar a la derecha. El resaltado debe seguir la geometría visible con cada ajuste.

El cálculo inverso ilustrativo y 12 comprobaciones de ida y vuelta entre el mapeo directo y el inverso se ejecutaron con operaciones aritméticas de Python. En el entorno de investigación no había compilador de Swift ni entorno de ejecución de la interfaz de macOS, así que se inspeccionó la aplicación Swift, pero no se compiló ni se probó de forma interactiva.

Si falla una comprobación, usa el síntoma para acotar la causa:

  • Un desplazamiento constante sugiere que el controlador de entrada pertenece a otra vista o que se omitió la panorámica.
  • Un error que aumenta con la distancia sugiere que falta una conversión de escala o que no coincide el orden de las transformaciones.
  • Un resultado reflejado verticalmente sugiere que se añadió una inversión del eje y o que no se convirtió un punto de AppKit.
  • Una selección incorrecta solo en zonas superpuestas sugiere que el orden de dibujo y el de detección de impactos no coinciden.

Almacena el punto local de seguimiento del puntero sin modificar, como hace el ejemplo, y deriva el punto del modelo a partir del mapeo actual. Así evitas conservar una posición del puntero en coordenadas del modelo calculada con un zoom anterior. La selección usa la ubicación propia del evento del gesto, así que no depende de una llamada anterior de seguimiento del puntero.

¿Qué cambia cuando el evento proviene de AppKit?

Si una vista personalizada de AppKit existente ya gestiona la interacción con el ratón, convierte el evento en ese límite antes de aplicar la inversa del dibujo. Normalmente, NSEvent.locationInWindow usa las coordenadas base de la ventana asociada. Apple señala que un evento con una ventana nil puede contener coordenadas de pantalla, así que primero confirma que el evento pertenece a la ventana de la vista receptora. Documentación de Apple sobre la ubicación de eventos.

Dentro de esa NSView receptora, la expresión convert(event.locationInWindow, from: nil) convierte un punto de ventana a coordenadas locales de la vista. Apple documenta nil como el origen de coordenadas de ventana para esta conversión. Solo entonces aplica la inversa de la transformación que usa el renderizador de esa vista. Documentación de Apple sobre conversión de puntos.

Comprueba la orientación en el límite de la vista. Una vista normal de AppKit tiene el origen abajo a la izquierda y el eje y aumenta hacia arriba; una vista cuyo isFlipped devuelve true tiene el origen arriba a la izquierda y el eje y aumenta hacia abajo. Una superficie de dibujo personalizada de AppKit puede adoptar esta última convención para coincidir con este modelo. Deja que la conversión de vista gestione las diferencias entre sistemas de coordenadas y evita añadir después otra inversión. Documentación de Apple sobre coordenadas invertidas.

Al exponer una vista de este tipo mediante NSViewRepresentable, mantén la gestión de eventos de AppKit en la vista representada y comunica los resultados mediante una devolución de llamada o un coordinador explícitos. Actualiza esa conexión durante las actualizaciones y libera los recursos propios durante el desmontaje. SwiftUI controla el diseño de la vista representada, así que no configures su frame ni su bounds para compensar errores de mapeo. Documentación de Apple sobre el ciclo de vida de vistas representables.

Para el ejemplo de SwiftUI puro, mantén la interacción asociada al lienzo. Si lo reemplazas por una superficie de AppKit, haz que esa superficie sea propietaria de la región de interacción. La conversión de coordenadas debe transformar un evento proporcionado por su propietario actual; no debería exigir que se envíe otra copia a un controlador de selección competidor.

¿Cómo debe evolucionar el mapeo para un lienzo más grande?

El ejemplo admite deliberadamente un zoom positivo uniforme y traslación. Si añades rotación, escalado no uniforme o un origen de dibujo distinto, reemplaza la inversa simple por la inversa de la transformación de dibujo completa. Rechaza los mapeos no invertibles antes de la interacción y conserva las comprobaciones de ida y vuelta con puntos conocidos.

Para arrastrar elementos en el futuro, registra el elemento y el desplazamiento de agarre en coordenadas del modelo al inicio del gesto. Actualiza ese mismo elemento durante todo el gesto y decide explícitamente si el mapeo de la ventana gráfica permanece fijo o cambia durante el arrastre. Repetir la detección de impactos para elegir un elemento nuevo en cada movimiento cambiaría quién controla la interacción cuando las formas se superponen.

Usa un espacio de coordenadas de la ventana gráfica, un mapeo de dibujo y una inversa correspondiente. Cuando coincidan, el seguimiento del puntero y la selección podrán operar sobre una geometría estable del modelo mientras el zoom y la panorámica cambian su apariencia.

Fuentes