Mac SwiftUI 캔버스에서 도형을 선택하려면 캔버스 뷰의 좌표 공간에서 포인터를 읽은 다음, 그리기에 사용한 변환을 역으로 적용하세요. 확대/축소와 패닝을 적용한 경우 도형을 검사하기 전에 패닝 오프셋을 빼고 확대/축소 값으로 나누세요.
이 튜토리얼에서는 합성 사각형 두 개가 있는 작은 데스크톱 캔버스를 만듭니다. 포인터 아래에 있는 사각형을 강조 표시하고, 포인터 제스처가 끝날 때 확대/축소나 패닝을 변경한 뒤에도 사각형을 선택합니다. 모든 그리기 데이터는 메모리에 유지됩니다.
포인터 이벤트에는 어떤 좌표 공간을 사용해야 하나요?
세 가지 공간을 구분하세요. 도형을 저장하는 좌표계, 캔버스 뷰의 로컬 좌표계, AppKit이 제공하는 윈도우 좌표입니다. 점이 어느 공간에 속하는지 알아야만 그 점을 유용하게 사용할 수 있습니다.
SwiftUI의 onContinuousHover(coordinateSpace:perform:)은 요청된 좌표 공간에서 포인터 위치를 제공합니다. 기본값은 .local입니다. 포인터가 뷰 안에 있는 동안 .active(location)을 전달하고, 뷰를 벗어나면 .ended를 전달합니다. 로컬 공간이 그리기 뷰포트와 일치하도록 캔버스에 직접 연결하세요. Apple의 연속 호버 문서.
상위 뷰가 상호작용 영역을 의도적으로 소유하는 경우 이름이 지정된 좌표 공간이 유용합니다. 이때 호버 핸들러와 제스처 핸들러 모두 같은 이름의 공간을 요청해야 합니다. Apple은 .local, .global, .named(_:)을 레이아웃 좌표 공간으로 정의합니다. 공간에 이름을 지정한다고 해서 애플리케이션의 그리기 모델이 정의되는 것은 아닙니다. Apple의 좌표 공간 문서.
이 구분이 중요한 이유는 Canvas가 즉시 모드 그리기를 수행하기 때문입니다. Apple 문서에 따르면 심볼을 포함한 개별 그리기 요소는 자체 상호작용 이벤트를 받지 않습니다. 이 경우 지원되는 방식은 포함하는 캔버스에서 상호작용을 처리하고 직접 모델 히트 테스트를 수행하는 것입니다. Apple의 Canvas 문서.
그리기 변환을 어떻게 역으로 적용하나요?
명확한 그리기 규칙을 정하세요. 이 예제에서는 모델 좌표가 오른쪽과 아래쪽으로 증가합니다. 확대/축소는 모델 단위를 스케일링하고, 패닝은 캔버스 로컬 포인트로 측정한 오프셋입니다. 정방향 매핑과 역변환은 다음과 같습니다.
viewX = modelX * zoom + panX
viewY = modelY * zoom + panY
modelX = (viewX - panX) / zoom
modelY = (viewY - panY) / zoom
이 방정식은 선택한 매핑을 따릅니다. 이를 그리려면 그래픽 컨텍스트를 패닝 값만큼 이동한 다음 확대/축소하세요. Apple 문서에 따르면 이 메서드들은 컨텍스트의 변환을 업데이트하고 이후 그리기 작업에 영향을 줍니다. 적용 순서를 바꾸면 매핑도 달라지므로, 역변환은 정확한 그리기 순서에 맞춰야 합니다. 이동 문서, 스케일링 문서.
계산을 예로 확인하려면 확대/축소 값 2, 패닝 값 (40, 30), 로컬 포인터 위치 (180, 110)을 사용하세요. 패닝을 빼면 (140, 80)가 되고, 확대/축소 값으로 나누면 모델 위치 (70, 40)가 됩니다. 이 모델 점을 그리면 다시 (180, 110)으로 돌아옵니다. 모델 프레임에 (70, 40)이 포함된 사각형이 있다면 히트되어야 합니다.
이 계산은 하나의 공유 타입 안에 두세요. 렌더러와 상호작용 코드는 서로 다른 오프셋 공식을 관리하지 말고 같은 매핑을 사용해야 합니다. 이 예제에서는 유한한 양수 확대/축소 값만 허용합니다. 0은 역산할 수 없습니다.
호버와 선택을 모두 지원하는 예제를 어떻게 만들까요?
배포 대상이 macOS 14 이상인 macOS SwiftUI 앱 프로젝트를 사용하세요. 이 배포 대상은 호버 수정자에 사용되는 CoordinateSpaceProtocol 오버로드를 지원합니다. 다음 모델 파일을 추가한 다음, 프로젝트에서 생성된 앱 선언을 두 번째 파일로 바꾸세요. 대상에는 @main 선언을 정확히 하나만 두세요. 호버 API의 사용 가능 버전 및 시그니처.
모델 도형과 매핑 정의하기
CanvasGeometry.swift를 만들고 다음 내용을 전부 넣으세요. 상호작용 전 과정에서 사각형 프레임은 모델 좌표로 유지됩니다.
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
}
도형과 매핑은 Swift 구조체입니다. 값 의미론을 사용하므로 별도의 공유 가변 객체를 만들지 않고도 현재 값을 그리기 및 히트 테스트 코드에 쉽게 전달할 수 있습니다. Swift 언어 가이드에서는 구조체를 값 타입으로 설명합니다. 구조체와 클래스.
같은 뷰포트에서 그리기와 상호작용 처리하기
PointerCanvasApp.swift를 만들고 생성된 앱 선언을 대체하세요. 이 파일은 완전한 예제이며 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)
}
}
캔버스는 도형 사이의 빈 공간을 포함한 직사각형 상호작용 영역을 사용합니다. contentShape(Rectangle())은 뷰의 히트 테스트 모양을 정의하고, 별도의 frame.contains 테스트는 포인터 아래에 어떤 합성 항목이 있는지 판별합니다. Apple의 콘텐츠 모양 문서.
선택에는 최소 거리가 0인 DragGesture를 사용하고, 끝 이벤트의 location을 읽습니다. 정의된 동작은 누른 동안 포인터가 움직였더라도 놓은 위치 아래의 사각형을 선택하는 것입니다. 따라서 이는 엄밀한 클릭 인식기가 아니라 놓은 위치를 기준으로 선택하는 방식입니다. Apple은 제스처의 거리 임계값과 좌표 공간 설정을 문서화하고, location을 현재 이벤트 위치로 설명합니다. DragGesture, DragGesture.Value.
항목은 배열 순서대로 그려지고 역순으로 검사됩니다. 따라서 두 사각형이 겹치는 위치에서는 사각형 2이 선택되며, 이는 이 예제의 시각적 쌓임 순서와 일치합니다. 빈 공간을 클릭하면 선택을 의도적으로 해제합니다. 도형 프레임과 배열의 내용은 바뀌지 않습니다.
뷰는 포인터 및 선택 상태를 소유하고 렌더러는 이를 읽습니다. Apple은 상태를 사용하는 가장 상위 뷰에 비공개 상태를 선언하고, 하위 뷰에 쓰기 권한이 필요한 경우 바인딩을 전달하도록 권장합니다. 나중에 부모 뷰가 항목 컬렉션이나 선택 상태를 소유한다면 해당 뷰로 소유권을 옮기고 필요한 값이나 바인딩을 이 뷰에 전달하세요. Apple의 상태 문서.
제스처를 더 추가하기 전에 매핑을 어떻게 확인할 수 있나요?
알려진 모델 점에서 시작해 예상되는 로컬 위치를 계산하세요. 다음 확인 지점은 예제의 초기 확대/축소 및 패닝 값을 사용합니다.
| 모델 점 | 예상 캔버스 로컬 점 | 예상 결과 |
|---|---|---|
(70, 40) |
(180, 110) |
사각형 1 |
(140, 90) |
(320, 210) |
겹치는 영역의 사각형 2 |
(10, 10) |
(60, 50) |
사각형 없음 |
로컬 위치는 캔버스 자체를 기준으로 측정합니다. 도구 모음과 바깥쪽 여백은 이 공간에 포함되지 않습니다. 두 사각형 위를 이동하고, 겹치는 곳에서 포인터를 놓은 다음 Zoom out과 Pan right를 사용해 보세요. 각 설정에서 강조 표시가 화면에 보이는 도형을 따라야 합니다.
예시 역변환 계산과 정방향 및 역방향 왕복 검사 12회는 Python 산술을 사용해 실행했습니다. 조사 환경에서 Swift 컴파일러와 macOS UI 런타임을 사용할 수 없었으므로 Swift 애플리케이션을 살펴보았지만 컴파일하거나 직접 상호작용 테스트를 하지는 않았습니다.
검사에 실패하면 증상을 이용해 원인을 좁히세요.
- 오프셋이 일정하다면 입력 핸들러가 다른 뷰에 연결되어 있거나 패닝 값이 빠졌을 수 있습니다.
- 거리가 멀수록 오차가 커진다면 스케일 변환이 누락되었거나 변환 순서가 일치하지 않을 수 있습니다.
- 결과가 수직으로 뒤집혀 있다면 y축을 추가로 뒤집었거나 AppKit 점을 변환하지 않았을 수 있습니다.
- 겹치는 영역에서만 선택이 잘못된다면 그리기 순서와 히트 테스트 순서가 일치하지 않을 수 있습니다.
예제처럼 원시 로컬 호버 위치를 저장하고 현재 매핑에서 모델 위치를 계산하세요. 그러면 이전 확대/축소 값에서 계산한 모델 공간 호버 위치를 계속 보유하지 않게 됩니다. 선택은 제스처 이벤트 자체의 위치를 사용하므로 이전 호버 콜백에 의존하지 않습니다.
이벤트가 AppKit에서 오면 어떻게 달라지나요?
기존 사용자 지정 AppKit 뷰가 이미 마우스 상호작용을 소유하고 있다면, 그 경계에서 이벤트를 변환한 뒤 그리기 역변환을 적용하세요. NSEvent.locationInWindow는 일반적으로 해당 윈도우의 기본 좌표를 사용합니다. Apple에 따르면 nil 윈도우의 이벤트에는 화면 좌표가 들어갈 수 있으므로, 먼저 이벤트가 수신 뷰의 윈도우에 속하는지 확인하세요. Apple의 이벤트 위치 문서.
수신하는 NSView 안에서 convert(event.locationInWindow, from: nil) 식을 사용하면 윈도우 점을 뷰의 로컬 좌표로 변환할 수 있습니다. Apple은 이 변환의 윈도우 좌표 원본으로 nil을 문서화합니다. 그다음에만 해당 뷰의 렌더러가 사용한 변환의 역을 적용하세요. Apple의 점 변환 문서.
뷰 경계에서 방향을 확인하세요. 일반 AppKit 뷰는 왼쪽 아래가 원점이며 y축이 위쪽으로 증가합니다. isFlipped가 true를 반환하는 뷰는 왼쪽 위가 원점이며 y축이 아래쪽으로 증가합니다. 사용자 지정 AppKit 그리기 영역은 이 모델에 맞춰 후자의 규칙을 채택할 수 있습니다. 뷰 좌표계 간 차이는 뷰 변환에 맡기고 그 뒤에 다시 뒤집지 마세요. Apple의 뒤집힌 좌표 문서.
NSViewRepresentable로 이러한 뷰를 노출하는 경우 AppKit 이벤트 처리는 표현된 뷰에 두고, 명시적인 콜백이나 코디네이터를 통해 결과를 전달하세요. 업데이트 중 연결을 새로 고치고, 해제 중에는 소유한 리소스를 정리하세요. SwiftUI가 표현된 뷰의 레이아웃을 제어하므로 매핑 오류를 보정하기 위해 frame이나 bounds을 설정하지 마세요. Apple의 표현 가능한 뷰 수명 주기 문서.
순수 SwiftUI 예제에서는 상호작용을 캔버스에 연결해 두세요. AppKit 표면으로 대체하는 경우 그 표면이 상호작용 영역을 소유하게 하세요. 좌표 변환은 기존 소유자가 제공한 이벤트를 변환해야 합니다. 서로 경쟁하는 별도의 선택 핸들러에 이벤트 사본을 다시 전달하도록 요구해서는 안 됩니다.
더 큰 캔버스에 맞춰 매핑을 어떻게 확장할 수 있나요?
이 예제는 의도적으로 양의 균일한 확대/축소와 이동만 지원합니다. 회전, 비균일 스케일링 또는 다른 그리기 원점을 추가한다면 단순한 역변환을 전체 그리기 변환의 역으로 바꾸세요. 상호작용 전에 역변환할 수 없는 매핑은 거부하고, 알려진 점을 사용한 정방향 및 역방향 검사도 유지하세요.
앞으로 항목을 드래그하는 기능을 추가할 때는 제스처가 시작될 때 항목과 모델 공간에서의 잡은 위치 오프셋을 기록하세요. 제스처가 진행되는 동안 같은 항목을 업데이트하고, 드래그 중 뷰포트 매핑을 고정할지 바꿀지 명시적으로 정하세요. 움직일 때마다 히트 테스트를 반복해 새 항목을 선택하면 도형이 겹칠 때 상호작용의 소유 항목이 바뀔 수 있습니다.
하나의 뷰포트 좌표 공간, 하나의 그리기 매핑, 이에 대응하는 역변환을 사용하세요. 이들이 일치하면 확대/축소와 패닝으로 표시 방식이 바뀌어도 호버와 선택은 안정적인 모델 도형을 대상으로 작동합니다.
출처
- 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