SwiftUI가 드래그를 미리 보는 동안 도형의 확정된 위치를 그대로 유지한 다음, onEnded에서 최종 이동량을 확정합니다. 이렇게 하면 AppKit의 마우스 버튼 놓기 시점에 의존하지 않고 완료될 때까지 시작 위치를 보존할 수 있습니다. Mac 앱에서 사각형을 이동하는 완전한 예제는 다음과 같습니다.
시작 상태는 어떤 완료 시점을 기준으로 유지해야 할까요?
도형 이동 상호작용에서 구체적인 작업은 모든 미리보기와 최종 위치를 동일한 시작 위치를 기준으로 계산하는 것입니다. 유용한 완료 시점은 해당 도형의 제스처가 성공적으로 완료되는 때입니다. Apple 문서에 따르면 onEnded(_:)는 제스처의 최종 값을 제공하며 제스처가 성공적으로 끝날 때만 실행됩니다. 제스처가 어떻게 끝나든 재설정해야 하는 임시 상태에는 @GestureState를 권장합니다. Apple의 onEnded(_:) 문서.
상호작용 상태를 두 종류로 나눕니다. 확정된 모델 위치와 임시 미리보기 이동량입니다. 드래그하는 동안에는 두 값을 더한 위치를 표시합니다. 성공적으로 완료되면 최종 이동량을 확정된 위치에 한 번 적용합니다. 이는 일시적인 제스처 업데이트와 영구적인 상태 업데이트를 구분하는 Apple의 설명에서 도출한 설계입니다. 제스처로 상호작용성 추가하기.
| 정보 | 이 예제에서 관리하는 곳 | 수명 |
|---|---|---|
| 사각형의 확정된 중심점 | 비공개 @State 모델 |
제스처 완료 후에도 유지 |
| 현재 미리보기 이동량 | @GestureState |
제스처가 비활성화되면 재설정 |
| 최종 이동량 | onEnded 콜백 인수 |
이동을 확정하는 데 동기적으로 사용 |
| AppKit의 마우스 버튼 놓기 이벤트 | AppKit 이벤트 처리 | 버튼이 놓였음을 알림. SwiftUI 제스처 완료를 정의하지는 않음 |
Apple 문서에서 @State는 SwiftUI가 관리하는 저장소이자 뷰 계층의 값에 대한 단일 진실 공급원입니다. @GestureState는 제스처가 비활성화되면 자동으로 재설정됩니다. 기준 위치는 전자에, 미리보기는 후자에 두면 각 값의 수명이 이 상호작용에 필요한 수명과 일치합니다. State, GestureState.
완전한 Mac 사각형 드래그 예제 만들기
Xcode에서 배포 대상이 macOS 14 이상인 macOS SwiftUI 앱을 만듭니다. 생성된 앱 선언과 콘텐츠 뷰를 다음 단일 파일로 바꾸고, 대상에 @main 선언이 하나만 있도록 합니다. 이 예제는 메모리에만 저장되는 단일 가상 사각형을 사용합니다. 명명된 좌표 공간 API는 macOS 14부터 사용할 수 있다고 문서화되어 있습니다. 드래그 제스처 초기화자, 명명된 좌표 공간.
import SwiftUI
@main
struct GestureStartDemoApp: App {
var body: some Scene {
WindowGroup {
ShapeCanvas()
}
}
}
private struct SampleItem {
var center = CGPoint(x: 160, y: 140)
}
private struct ShapeCanvas: View {
@State private var item = SampleItem()
@GestureState private var previewTranslation = CGSize.zero
private let canvasWidth: CGFloat = 600
private let canvasHeight: CGFloat = 360
private let shapeWidth: CGFloat = 80
private let shapeHeight: CGFloat = 60
private func projectedCenter(_ translation: CGSize) -> CGPoint {
let proposedX = item.center.x + translation.width
let proposedY = item.center.y + translation.height
let halfWidth = shapeWidth / 2
let halfHeight = shapeHeight / 2
return CGPoint(
x: min(max(proposedX, halfWidth),
canvasWidth - halfWidth),
y: min(max(proposedY, halfHeight),
canvasHeight - halfHeight)
)
}
private var moveGesture: some Gesture {
DragGesture(
minimumDistance: 3,
coordinateSpace: .named("shapeCanvas")
)
.updating($previewTranslation) { value, translation, _ in
translation = value.translation
}
.onEnded { value in
let finalCenter = projectedCenter(value.translation)
item.center = finalCenter
}
}
var body: some View {
let displayedCenter = projectedCenter(previewTranslation)
VStack(alignment: .leading, spacing: 12) {
Text("Drag the blue rectangle")
.font(.headline)
ZStack {
Color.gray.opacity(0.12)
RoundedRectangle(cornerRadius: 10)
.fill(Color.blue)
.frame(width: shapeWidth, height: shapeHeight)
.contentShape(Rectangle())
.gesture(moveGesture)
.position(
x: displayedCenter.x,
y: displayedCenter.y
)
}
.frame(width: canvasWidth, height: canvasHeight)
.coordinateSpace(.named("shapeCanvas"))
.clipped()
Text(
"Committed center: " +
"\(Int(item.center.x)), \(Int(item.center.y))"
)
.monospacedDigit()
}
.padding(20)
}
}
핵심 불변 조건은 item.center가 updating에서 절대 바뀌지 않는다는 점입니다. onEnded가 finalCenter을 계산할 때까지 제스처의 모델 기준점으로 유지됩니다. 최종 계산은 value.translation를 직접 읽으므로, 완료 중에 미리보기 속성이 마지막 값을 유지할 필요가 없습니다.
제스처는 바깥 캔버스의 명명된 좌표 공간에서 이동을 측정합니다. 사각형의 position(x:y:)은 같은 부모 공간에 중심점을 배치합니다. 좌표 공간을 일치시키면 움직이는 도형의 로컬 좌표와 캔버스 좌표를 혼용하지 않아도 됩니다. Apple 문서는 명명된 공간에서의 제스처 측정과 중심점을 기준으로 한 위치 지정을 모두 설명합니다. coordinateSpace(_:), position(x:y:).
공유 projectedCenter 함수는 사각형이 캔버스 안에 머물도록 합니다. 미리보기와 확정에는 동일한 경계가 적용됩니다. 가로 중심점은 40부터 560까지이고, 세로 중심점은 30부터 330까지입니다. 가장자리에 닿아도 이동량은 드래그 시작점으로부터의 총 변위를 기준으로 합니다. 제안된 위치가 경계 안으로 돌아오면 사각형도 가장자리에서 벗어납니다.
설정된 3포인트 인식 임계값보다 작은 움직임은 인식된 드래그로 간주되지 않습니다. 이 예제에서는 인식된 도형 이동 상호작용이 시작되는 시점을 시작점으로 의도적으로 사용합니다. 물리적인 마우스 버튼 누르기 이벤트 순간의 모델 상태를 캡처해야 하는 요구사항은 다른 경계를 기준으로 합니다. 드래그 제스처 초기화자.
시작 위치가 최종 업데이트까지 유지되는 이유
Apple은 DragGesture.Value.translation을 제스처 시작부터 현재 이벤트까지의 총 이동량으로 정의합니다. 이는 콜백 직전의 이동량에 더해지는 값이 아니라 누적값입니다. 따라서 보고된 각 이동량을 이미 업데이트된 모델 위치에 더하면 움직임이 중복 계산됩니다. DragGesture.Value.
캔버스 경계 안에 있는 다음 명시적 예시 입력을 살펴보세요.
| 단계 | 보고된 총 이동량 | 확정된 중심점 | 표시되는 중심점 |
|---|---|---|---|
| 드래그 전 | (0, 0) |
(160, 140) |
(160, 140) |
| 첫 번째 업데이트 | (12, 5) |
(160, 140) |
(172, 145) |
| 두 번째 업데이트 | (30, -10) |
(160, 140) |
(190, 130) |
| 성공적으로 종료 | (45, 20) |
(205, 160)가 됨 |
(205, 160)에 정착 |
모든 미리보기는 동일한 기준점을 사용합니다. 완료 시 최종 (45, 20)을 한 번 적용합니다. 이후 이동량이 (-15, 10)인 드래그가 성공적으로 진행되면 (205, 160)에서 시작해 (190, 170)를 확정합니다. 이러한 계산은 결정론적 Python 계산으로 확인했습니다. SwiftUI 앱은 가져온 API 문서와 대조해 검토했지만, 이 환경에서 컴파일하거나 실행하지는 않았습니다.
사각형의 시작 중심점 대신 startLocation을 사용하지 마세요. Apple은 startLocation을 드래그의 첫 이벤트가 발생한 위치로 정의합니다. 포인터는 사각형의 모서리 근처에서 시작할 수 있으므로 그 위치와 도형의 모델 중심점은 다를 수 있습니다. 마찬가지로 predictedEndTranslation은 속도를 기반으로 예측한 값입니다. 직접 배치하는 이 상호작용에는 최종 translation을 사용하세요. DragGesture.Value.
이 설계에서는 미리보기 업데이트가 확정된 위치를 덮어쓰지 않으므로 취소 시 확정된 위치를 되돌릴 필요가 없습니다. Apple 문서에 따르면 제스처 상태는 제스처가 비활성화되면 재설정되며, onEnded는 성공한 경우에만 실행됩니다. 따라서 취소된 이동은 확정되지 않고 임시 이동량이 사라집니다. 이 결론은 문서화된 동작에서 도출되며, 재설정과 성공 콜백 중 어느 것이 먼저 실행되는지 가정할 필요가 없습니다. GestureState, onEnded(_:).
AppKit의 마우스 버튼 놓기 모니터에서 기준 상태를 지우면 안 되는 이유
AppKit 로컬 이벤트 모니터는 이벤트가 앱으로 전달되기 전에 이벤트를 받습니다. 모니터링 중인 마우스 버튼 놓기 이벤트에서 공유된 제스처 시작 상태를 지우면, 후속 처리가 해당 이벤트를 처리하기 전에 상태가 사라질 수 있습니다. 이는 문서화된 전달 경계의 결과이지, 문서화된 SwiftUI 콜백 순서 보장이 아닙니다. addLocalMonitorForEvents(matching:handler:).
마찬가지로 NSResponder.mouseUp(with:)은 사용자가 왼쪽 마우스 버튼을 놓았음을 알립니다. 해당 계약은 모든 SwiftUI 제스처 콜백이나 뷰 업데이트가 완료되었다고 선언하지 않습니다. 원시 이벤트 관찰과 인식된 제스처의 완료는 별개의 책임으로 두세요. mouseUp(with:).
여기서 검토한 API는 SwiftUI가 원시 이벤트와 관련된 처리를 모두 마쳤다는 일반적인 콜백 의미를 정립하지 않습니다. 대기열에 정리 블록을 넣거나 짧은 타이머를 사용하면 그 관계를 입증하지 않은 채 스케줄링에 대한 가정만 추가됩니다. 이 작업에서는 최종 제스처 값을 사용할 수 있는 onEnded 안에서 최종 모델 계산을 수행하는 것이 지원되는 방법입니다.
상호작용을 사용자 지정 AppKit 뷰에서 구현해야 한다면 해당 뷰가 입력 시퀀스와 세션 상태를 소유하게 하고, NSViewRepresentable을 통해 통합하세요. Apple은 이 통합을 위한 생성, 업데이트, 코디네이터 및 해체 훅을 제공합니다. 표현 가능한 뷰의 세션은 각 representable 값에서 다시 만들지 말고 AppKit 뷰 또는 해당 코디네이터에서 유지하세요. SwiftUI가 표현된 뷰의 레이아웃을 제어하므로 관리되는 프레임이나 바운드를 독자적으로 변경하지 마세요. NSViewRepresentable.
상호작용에 명시적 스냅샷이 필요한 경우
드래그가 활성화된 동안 다른 작업이 확정된 위치를 변경하지 않으므로 이 예제에서는 확정된 위치만으로 충분합니다. 더 큰 캔버스에서는 별도 세션 객체를 도입하기 전에 다음 판단 기준을 사용하세요.
| 요구사항 | 설계에 미치는 결과 |
|---|---|
| 미리보기 중 확정된 위치가 유지되는 항목 하나를 이동 | 예시의 기준점 및 미리보기 방식을 사용 |
| 선택된 항목 여러 개를 함께 이동 | 안정적인 식별자와 시작 위치를 하나의 상호작용 스냅샷으로 보존 |
| 드래그 중 다른 명령으로 위치를 편집할 수 있도록 허용 | 편집 차단, 드래그 취소, 모델 버전을 기준으로 해결 중 어떤 방식을 쓸지 정의 |
| 인식되기 전에 물리적인 마우스 버튼 누르기 시점의 상태를 캡처 | 명시적으로 소유되는 AppKit 입력 경계를 사용. 첫 제스처 업데이트와 같지 않음 |
| 완료 후에도 별도 세션 유지 | 임시 제스처 상태에 기대지 말고 명시적 소유자와 종료 정책을 지정 |
스냅샷에는 값을 보존해야 합니다. Swift 구조체에는 값 의미 체계가 있지만, 클래스 인스턴스를 할당하면 참조가 복사됩니다. 따라서 변경 가능한 항목 객체의 참조 배열만으로는 위치가 고정되지 않습니다. 여러 항목을 이동할 때는 최종 계산에 필요한 식별자와 좌표 값을 캡처하세요. 구조체와 클래스 — Swift 프로그래밍 언어.
영구 세션을 도입한다면 성공적인 완료, 취소, 뷰 제거를 명시적으로 정의하세요. onEnded에서만 세션을 지우면 취소가 처리되지 않습니다. 해당 콜백은 성공한 경우에만 실행되기 때문입니다. 제스처 상태 재설정 트랜잭션을 보편적인 완료 알림으로 재사용하지 마세요. Apple 문서는 재설정 클로저가 제스처 상태를 재설정하기 위한 트랜잭션을 제공한다고 설명합니다. onEnded(_:), init(wrappedValue:reset:).
일반적인 사각형 이동 작업에서는 미리보기 중 확정된 모델을 그대로 두어 기준점을 보존합니다. 성공한 제스처의 자체 값에서 최종 위치를 계산해 한 번 확정하고, 임시 제스처 상태는 재설정되도록 합니다. 이렇게 하면 시작 위치에 명확한 소유자와 필요한 계산 동안의 수명이 부여됩니다.
출처
- 제스처로 상호작용성 추가하기 — Apple.
- State — Apple.
- GestureState — Apple.
- onEnded(_:) — Apple.
- DragGesture.Value — Apple.
- init(minimumDistance:coordinateSpace:) — Apple.
- coordinateSpace(_:) — Apple.
- position(x:y:) — Apple.
- addLocalMonitorForEvents(matching:handler:) — Apple.
- mouseUp(with:) — Apple.
- NSViewRepresentable — Apple.
- 구조체와 클래스 — Swift 프로그래밍 언어 — Swift.org 프로젝트.
- init(wrappedValue:reset:) — Apple.