Para selecionar formas em um canvas do SwiftUI no Mac, leia a posição do ponteiro no espaço de coordenadas da visualização do canvas e, em seguida, inverta a transformação usada no desenho. Com zoom e deslocamento, subtraia o deslocamento e divida pelo zoom antes de verificar as formas.

Este tutorial cria um pequeno canvas para desktop com dois retângulos sintéticos. Você destacará o retângulo sob o ponteiro e selecionará um retângulo quando um gesto do ponteiro terminar, inclusive após alterar o zoom ou o deslocamento. Todos os dados do desenho permanecem na memória.

Qual espaço de coordenadas deve receber os eventos do ponteiro?

Mantenha distintos três espaços: o sistema de coordenadas que armazena suas formas, o sistema de coordenadas local da visualização do canvas e quaisquer coordenadas da janela fornecidas pelo AppKit. Um ponto só é útil quando você sabe a qual espaço ele pertence.

onContinuousHover(coordinateSpace:perform:) do SwiftUI fornece as posições do ponteiro no espaço de coordenadas solicitado; o padrão é .local. Ele fornece .active(location) enquanto o ponteiro está dentro da visualização e .ended quando ele sai. Anexe-o diretamente ao canvas para que seu espaço local corresponda à área de desenho. Documentação da Apple sobre hover contínuo.

Um espaço de coordenadas nomeado é útil quando um ancestral é intencionalmente responsável pela região de interação. Nesse caso, os manipuladores de hover e de gesto devem solicitar o mesmo espaço nomeado. A Apple define .local, .global e .named(_:) como espaços de coordenadas de layout; nomear um espaço não define o modelo de desenho do aplicativo. Documentação da Apple sobre espaços de coordenadas.

Essa distinção importa porque Canvas realiza desenho em modo imediato. A Apple documenta explicitamente que elementos desenhados individuais, inclusive símbolos, não recebem interatividade própria. A abordagem compatível aqui é tratar a interação no canvas que os contém e fazer você mesmo o teste de acerto no modelo. Documentação da Apple sobre Canvas.

Como inverter a transformação do desenho?

Escolha uma convenção de desenho explícita. Neste exemplo, as coordenadas do modelo aumentam para a direita e para baixo. O zoom dimensiona as unidades do modelo, e o deslocamento é um offset medido em pontos locais do canvas. O mapeamento direto e seu inverso são:

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

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

Essas equações decorrem do mapeamento escolhido. Para desenhá-lo, aplique uma translação ao contexto gráfico pelo deslocamento e, em seguida, aplique a escala pelo zoom. A Apple documenta que esses métodos atualizam a transformação do contexto e afetam as operações de desenho subsequentes. Inverter a ordem altera o mapeamento, portanto mantenha o inverso correspondente à sequência exata de desenho. Documentação sobre translação, documentação sobre escala.

Para uma verificação ilustrativa, use zoom 2, deslocamento (40, 30) e uma posição local do ponteiro (180, 110). Subtrair o deslocamento resulta em (140, 80); dividir pelo zoom resulta na posição do modelo (70, 40). Desenhar esse ponto do modelo o leva de volta a (180, 110). Portanto, deve haver um acerto em um retângulo cujo quadro no modelo contenha (70, 40).

Mantenha esse cálculo em um único tipo compartilhado. O renderizador e o código de interação devem usar o mesmo mapeamento, em vez de manter fórmulas de deslocamento separadas. O exemplo aceita apenas valores de zoom finitos e positivos; zero não pode ser invertido.

Crie um exemplo completo de hover e seleção

Use um projeto de aplicativo SwiftUI para macOS com destino de implantação macOS 14 ou posterior. Esse destino oferece suporte à sobrecarga de CoordinateSpaceProtocol usada pelo modificador de hover. Adicione o arquivo de modelo a seguir e, em seguida, substitua a declaração de aplicativo gerada pelo projeto pelo segundo arquivo. Mantenha exatamente uma declaração @main no destino. Disponibilidade e assinatura da API de hover.

Defina a geometria do modelo e seu mapeamento

Crie CanvasGeometry.swift com o conteúdo completo a seguir. Os quadros dos retângulos permanecem nas coordenadas do modelo durante toda a interação.

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
}

A geometria e o mapeamento são estruturas Swift. A semântica de valor facilita passar os valores atuais ao código de desenho e de teste de acerto sem introduzir outro objeto mutável compartilhado. O guia da linguagem Swift documenta as estruturas como tipos de valor. Structures and Classes.

Desenhe e interaja com a mesma área de visualização

Crie PointerCanvasApp.swift, substituindo a declaração de aplicativo gerada. Este arquivo completo usa os 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)
    }
}

O canvas usa uma região de interação retangular, inclusive o espaço vazio entre as formas. contentShape(Rectangle()) define a forma usada para testar acertos da visualização; o teste separado frame.contains decide qual item sintético está sob o ponteiro. Documentação da Apple sobre formas de conteúdo.

A seleção usa um DragGesture com distância mínima zero e lê o location do evento final. Seu comportamento definido é selecionar o retângulo sob a posição de soltura, mesmo que o ponteiro tenha se movido enquanto estava pressionado. Trata-se de um seletor pela posição de soltura, e não de um reconhecedor estrito de cliques. A Apple documenta o limiar de distância e a configuração do espaço de coordenadas do gesto, e identifica location como a posição atual do evento. DragGesture, DragGesture.Value.

Os itens são desenhados na ordem do array e testados na ordem inversa. Assim, o retângulo 2 prevalece onde os dois se sobrepõem, correspondendo à ordem visual de camadas deste exemplo. Clicar em um espaço vazio limpa a seleção intencionalmente. Os quadros das formas e o conteúdo do array nunca mudam.

A visualização possui o estado do ponteiro e da seleção, enquanto o renderizador o lê. A Apple recomenda declarar o estado privado na visualização de nível mais alto que precisar dele e passar bindings quando as visualizações descendentes precisarem de acesso de escrita. Se futuramente um elemento pai for responsável pela coleção de itens ou pela seleção, transfira essa responsabilidade para ele e passe os valores ou bindings necessários a esta visualização. Documentação da Apple sobre estado.

Como verificar o mapeamento antes de adicionar mais gestos?

Comece com pontos conhecidos do modelo e calcule as posições locais esperadas. Estes pontos de verificação usam o zoom e o deslocamento iniciais do exemplo:

Ponto do modelo Ponto local esperado no canvas Resultado esperado
(70, 40) (180, 110) Retângulo 1
(140, 90) (320, 210) Retângulo 2, na sobreposição
(10, 10) (60, 50) Nenhum retângulo

As posições locais são medidas a partir do próprio canvas; a barra de ferramentas e o preenchimento externo ficam fora desse espaço. Mova o ponteiro pelos dois retângulos, solte-o sobre a área de sobreposição e, em seguida, experimente Diminuir zoom e Deslocar para a direita. O destaque deve acompanhar a geometria visível em cada configuração.

O cálculo inverso ilustrativo e 12 verificações de ida e volta do mapeamento foram executados usando aritmética em Python. Não havia um compilador Swift nem um ambiente de execução da interface do macOS disponíveis no ambiente de pesquisa, portanto o aplicativo Swift foi inspecionado, mas não compilado nem testado interativamente.

Se uma verificação falhar, use o sintoma para delimitar a causa:

  • Um deslocamento constante sugere que o manipulador de entrada pertence a outra visualização ou que o deslocamento foi omitido.
  • Um erro que aumenta com a distância sugere que falta uma conversão de escala ou que a ordem da transformação não corresponde.
  • Um resultado espelhado verticalmente sugere uma inversão extra do eixo y ou um ponto do AppKit sem conversão.
  • Uma seleção incorreta apenas nas regiões sobrepostas sugere inconsistência entre a ordem do desenho e a do teste de acerto.

Armazene a posição bruta local do hover, como faz o exemplo, e derive dela a posição do modelo usando o mapeamento atual. Isso evita manter uma posição de hover no espaço do modelo calculada com um zoom anterior. A seleção usa a própria posição do evento do gesto, portanto não depende de uma chamada de callback de hover anterior.

O que muda quando o evento vem do AppKit?

Se uma visualização AppKit personalizada já existente controla a interação com o mouse, converta o evento nesse limite antes de aplicar o inverso do desenho. NSEvent.locationInWindow normalmente usa as coordenadas base da janela associada. A Apple observa que um evento com uma janela nil pode, em vez disso, conter coordenadas da tela, portanto confirme primeiro que o evento pertence à janela da visualização que o recebe. Documentação da Apple sobre localização do evento.

Dentro dessa NSView que recebe o evento, a expressão convert(event.locationInWindow, from: nil) converte um ponto da janela em coordenadas locais da visualização. A Apple documenta nil como a origem das coordenadas da janela para essa conversão. Só então aplique o inverso da transformação usada pelo renderizador dessa visualização. Documentação da Apple sobre conversão de pontos.

Verifique a orientação no limite da visualização. Uma visualização AppKit comum tem origem no canto inferior esquerdo, com y aumentando para cima; uma visualização cujo isFlipped retorna true tem origem no canto superior esquerdo, com y aumentando para baixo. Uma superfície de desenho AppKit personalizada pode adotar esta última convenção para corresponder ao modelo. Deixe a conversão da visualização lidar com as diferenças entre sistemas de coordenadas das visualizações e evite adicionar outra inversão depois. Documentação da Apple sobre coordenadas invertidas.

Ao expor essa visualização por meio de NSViewRepresentable, mantenha o tratamento de eventos do AppKit na visualização representada e comunique os resultados por meio de um callback ou coordenador explícito. Atualize essa conexão durante as atualizações e libere os recursos sob sua responsabilidade durante a finalização. O SwiftUI controla o layout da visualização representada, portanto não defina frame nem bounds para compensar erros de mapeamento. Documentação da Apple sobre o ciclo de vida de representáveis.

No exemplo puramente SwiftUI, mantenha a interação anexada ao canvas. Se uma superfície AppKit o substituir, atribua a ela a responsabilidade pela região de interação. A conversão de coordenadas deve transformar um evento fornecido pelo responsável existente; ela não deve exigir o encaminhamento de uma segunda cópia para outro manipulador de seleção concorrente.

Como o mapeamento deve evoluir para um canvas maior?

O exemplo oferece suporte intencionalmente a zoom positivo uniforme e translação. Se você adicionar rotação, escala não uniforme ou outra origem de desenho, substitua o inverso simples pelo inverso da transformação de desenho completa. Rejeite mapeamentos não invertíveis antes da interação e mantenha as verificações de ida e volta com pontos conhecidos.

Para arrastar itens no futuro, registre o item e o deslocamento de captura no espaço do modelo no início do gesto. Atualize esse mesmo item durante todo o gesto; escolha explicitamente se o mapeamento da área de visualização permanece fixo ou muda durante o arraste. Repetir o teste de acerto para escolher um novo item a cada movimento alteraria a propriedade da interação sempre que as formas se sobrepusessem.

Use um espaço de coordenadas da área de visualização, um mapeamento de desenho e um inverso correspondente. Quando eles estiverem alinhados, o hover e a seleção poderão operar sobre uma geometria estável do modelo enquanto o zoom e o deslocamento alteram sua aparência.

Fontes