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
- Canvas — Apple
- onContinuousHover(coordinateSpace:perform:) — Apple
- CoordinateSpaceProtocol — Apple
- translateBy(x:y:) — Apple
- scaleBy(x:y:) — Apple
- Structures and Classes — Swift.org
- contentShape(_:eoFill:) — Apple
- DragGesture — Apple
- DragGesture.Value — Apple
- State — Apple
- locationInWindow — Apple
- convert(_:from:) — Apple
- isFlipped — Apple
- NSViewRepresentable — Apple