드래그 소스의 세션 종료 콜백에서 흐리게 표시된 도형, 윤곽선, 드래그 레이블을 복원합니다. 대상의 이탈 콜백은 포인터가 해당 대상에서 벗어났다는 뜻일 뿐입니다. Mac 앱에서는 사용 가능한 경우 SwiftUI의 소스 세션 API를 사용하거나, AppKit 소스가 드래그를 소유하도록 하세요.
이 가이드는 사용자가 드래그를 마친 뒤 합성 도형을 원래 모양으로 되돌리는 한 가지 작업을 다룹니다. 목적지가 드롭을 수락하지 않는 경우도 포함합니다. 예제는 콘텐츠를 메모리에 보관하고, 수락된 드롭을 복사로 처리하므로 피드백을 복원할 때 소스를 삭제하거나 이동할 필요가 없습니다.
드롭 대상 밖에서도 드래그 피드백이 계속 활성화되는 이유는 무엇인가요?
흔히 드래그가 시작될 때 isDragging 플래그를 설정하고 performDrop에서 해제하는 방식으로 구현합니다. 이렇게 하면 정리 작업이 드롭을 수락한 대상만 처리할 수 있는 이벤트에 연결됩니다. 창의 빈 공간에 놓으면 소스를 초기화할 책임이 있는 대상이 없습니다.
Apple은 DropDelegate를 드롭을 수락하도록 수정된 뷰를 위한 인터페이스로 정의합니다. dropExited(info:) 메서드는 유효성이 확인된 드래그가 해당 뷰에서 벗어났음을 알립니다. 드래그는 다른 대상으로 계속 이동하거나, 첫 번째 대상으로 돌아오거나, 다른 곳에서 끝날 수 있습니다. 따라서 대상 이탈만으로는 소스 세션이 끝났다고 판단할 수 없습니다. DropDelegate
dropDestination(for:action:isTargeted:)에도 같은 구분이 적용됩니다. isTargeted은 포인터가 대상 영역 안에 있는지를 나타냅니다. 이를 대상의 테두리나 배경에 사용하세요. 소스의 흐림 효과나 드래그 레이블은 소스 세션 콜백이 제어하도록 하세요. dropDestination(for:action:isTargeted:)
이 문서화된 경계에서 유용한 설계 원칙을 도출할 수 있습니다. 각 시각 상태를 확실하게 종료할 수 있는 이벤트 소유자에게 맡기세요.
| 피드백 또는 결과 | 소유자 | 초기화 또는 확정 시점 |
|---|---|---|
| 흐리게 표시된 소스 도형 | 드래그 소스 | 소스 세션 종료 시 |
| 강조 표시된 대상 | 드롭 대상 | 포인터가 벗어날 때. 로컬 드래그는 소스 완료 시 방어적으로 정리 |
| 수락된 합성 항목 | 대상/모델 | 드롭의 유효성이 확인되고 성공했을 때 |
| 임시 내보내기 리소스 | 전송 수명 주기 | 해당하는 경우 전송 완료 시 |
Mac 앱은 지원되는 어떤 방식을 사용해야 하나요?
Apple의 최신 레퍼런스에 따르면 onDragSessionUpdated(_:)는 macOS 26 이상에서 사용할 수 있습니다. 이 API는 자식 뷰 계층에서 가장 가까운 드래그 세션의 업데이트를 관찰합니다. 범위를 명확히 하려면 소스의 드래그 수정자 바로 바깥에 연결하세요. 해당 메서드가 실제로 선언된 SDK를 사용해야 합니다. 런타임에서 사용할 수 있더라도 SDK에 선언이 없으면 컴파일되지 않습니다. onDragSessionUpdated(_:)
문서 사이에 불일치가 있으므로 필요한 부분만 주의해서 처리하세요. 수정자의 예제에는 위치와 작업 인수를 받는 종료 단계가 나오지만, 전용 단계 레퍼런스에는 ended(DropOperation)로 선언되어 있습니다. 시각적 복원에는 연관 값이 필요하지 않으므로 아래 예제는 연관 값을 추출하지 않고 .ended만 일치시킵니다. 작업별 동작을 추가하기 전에 설치된 SDK의 선언을 확인하세요. DragSession.Phase.ended(_:)
배포 대상이나 SDK에서 SwiftUI 콜백을 사용할 수 없다면 AppKit 드래그 소스가 문서화된 완료 경계를 제공합니다. Apple은 드래그가 끝나거나 취소되면 소스가 draggingSession(_:endedAt:operation:)을 받는다고 명시합니다. 사용자 지정 소스는 해당 세션을 직접 시작해야 합니다. beginDraggingSession(with:event:source:)
하나의 캔버스 안에서만 도형의 위치를 바꾸려는 경우 시스템 드래그 앤 드롭 세션 대신 로컬 DragGesture를 고려하세요. @GestureState는 제스처가 비활성화되면 초기화되므로 해당 제스처의 임시 피드백에 적합합니다. 전송 가능한 콘텐츠를 대상에 제공하는 시스템 드래그와는 별도로 선택하세요. GestureState
SwiftUI에서 소스가 소유하는 정리를 구현하는 방법
onDragSessionUpdated(_:)를 제공하는 SDK로 macOS SwiftUI 앱을 만들고, 배포 대상을 macOS 26 이상으로 설정한 다음, 생성된 앱 및 콘텐츠 뷰 선언을 이 완전한 프로그램으로 바꾸세요. 이 프로그램은 합성 문자열 shape.circle 하나와 수신 영역 하나를 제공합니다.
소스는 draggable(_:)을 사용해 전송 가능한 콘텐츠를 제공합니다. 대상은 일치하는 문자열을 정확히 하나만 수락하며, 다른 입력에는 false을 반환합니다. Apple의 문서에 따르면 대상의 Boolean 반환값은 드롭 성공 여부를 나타냅니다. draggable(_:), dropDestination(for:action:isTargeted:)
import SwiftUI
@main
struct DragFeedbackDemoApp: App {
var body: some Scene {
WindowGroup {
DragFeedbackDemo()
}
}
}
struct DragFeedbackDemo: View {
private let payload = "shape.circle"
@State private var isDragging = false
@State private var isTargeted = false
@State private var acceptedCopies = 0
var body: some View {
VStack(spacing: 24) {
Text("Drag the circle into the receiving area")
Circle()
.fill(Color.blue)
.frame(width: 72, height: 72)
.opacity(isDragging ? 0.4 : 1)
.draggable(payload)
.onDragSessionUpdated { session in
switch session.phase {
case .initial, .active:
isDragging = true
case .ended, .dataTransferCompleted:
restoreFeedback()
default:
break
}
}
Text(isDragging ? "Dragging" : "Ready")
RoundedRectangle(cornerRadius: 12)
.fill(isTargeted
? Color.blue.opacity(0.2)
: Color.gray.opacity(0.12))
.overlay {
Text("Receiving area")
}
.frame(width: 280, height: 120)
.dropDestination(for: String.self) { items, _ in
guard items.count == 1,
items.first == payload else {
return false
}
acceptedCopies += 1
return true
} isTargeted: { targeted in
isTargeted = targeted
}
Text("Accepted copies: \(acceptedCopies)")
}
.padding(32)
.frame(minWidth: 380, minHeight: 400)
}
private func restoreFeedback() {
isDragging = false
isTargeted = false
}
}
두 플래그의 역할은 다릅니다. isDragging는 소스의 불투명도를 바꿉니다. isTargeted는 수신 영역의 채우기 색을 바꿉니다. 따라서 수신 영역에 들어가거나 나가도 드래그가 계속되는 동안 소스가 실수로 복원되지 않습니다.
단계 레퍼런스에서는 initial을 세션 시작 직전, active를 이동 중, ended을 완료 시점으로 설명합니다. 예제는 ended에서 피드백을 복원하고, dataTransferCompleted에서도 같은 초기화를 반복해도 문제가 없도록 합니다. 실패한 드래그를 복원할 때 전송 완료만 기다리지 않습니다. DragSession.Phase
Apple은 dataTransferCompleted을 드래그된 항목의 전송이 완료되고 임시 항목을 정리할 수 있는 시점으로 설명합니다. 이 예제에는 임시 내보내기 리소스가 없습니다. 더 큰 구현에서는 시각적 정리와 리소스 정리를 분리해 불투명도를 복원하는 작업이 전송에 필요한 항목을 버리지 않도록 하세요. DragSession.Phase.dataTransferCompleted
세션이 진행되는 동안 원은 뷰 계층에 계속 남아 있습니다. 드롭이 수락되면 대상의 개수가 증가하고, 수락되지 않으면 콘텐츠가 바뀌지 않습니다. 이 예제는 순서 변경이나 이동 의미를 고려하지 않고 피드백을 따로 진단할 때 유용합니다. 검색해 확인한 선언을 기준으로 코드를 검토했으며, 사용 가능한 환경이 SwiftUI가 없는 Linux이므로 컴파일하거나 실행하지는 않았습니다.
AppKit 대체 구현에서 이벤트 소유권을 유지하는 방법
더 오래된 macOS 버전을 지원하려면 드래그 소스 구현을 NSViewRepresentable로 호스팅하는 NSView로 바꾸세요. 이 표현 가능 타입은 네이티브 뷰를 만들고 업데이트하며 콜백을 SwiftUI 상태에 연결합니다. SwiftUI가 표현된 뷰의 레이아웃을 제어하므로 표현된 뷰의 프레임을 직접 지정하지 말고 SwiftUI 크기 수정자를 사용하세요. NSViewRepresentable
대체 구현은 소스가 소유하는 하나의 흐름으로 구성하세요.
- 네이티브 소스 뷰의
mouseDown(with:)에서 해당 뷰에 전달된 실제 마우스 다운 이벤트를 보관합니다. mouseDragged(with:)에서 선택한 드래그 임계값을 적용하고 두 번째 세션이 시작되지 않도록 합니다.- 합성 항목 식별자를 담은 메모리 내 페이스트보드 작성자로
NSDraggingItem을 만듭니다. - 생성한 도형 이미지를 제공하고 소스 뷰의 좌표계를 기준으로 드래그 프레임을 설정합니다.
- 보관한 마우스 다운 이벤트와
NSDraggingSource를 구현하는 객체를 사용해beginDraggingSession(with:event:source:)를 호출합니다. draggingSession(_:willBeginAt:)에서 소스 피드백을 표시하고draggingSession(_:endedAt:operation:)에서 항상 복원합니다.- 추적이 끝나면 보관한 입력 이벤트와 세션 기록을 지웁니다. 세션이 완료될 때까지 소스와 콜백 소유자를 유지합니다.
이 세부 사항은 AppKit의 명시적 계약을 따릅니다. 세션 시작 메서드는 시작을 알린 마우스 다운 이벤트를 받고, 드래그 항목의 프레임은 뷰 좌표계를 사용하며, 소스는 수명 주기 메시지를 받습니다. NSDraggingItem은 NSPasteboardWriting을 따르는 객체를 받습니다. 단일 이미지 편의 메서드는 드래그 프레임과 콘텐츠를 설정합니다. beginDraggingSession(with:event:source:), init(pasteboardWriter:), setDraggingFrame(_:contents:)
이 앱 내부 상호작용에서는 NSDraggingContext.withinApplication에 .copy를 반환하고 다른 컨텍스트에는 빈 작업 집합을 반환하세요. 이는 로컬 복사만 제공하겠다는 명시적 선택입니다. AppKit은 draggingSession(_:sourceOperationMaskFor:)를 통해 소스가 허용하는 작업을 확인합니다. draggingSession(_:sourceOperationMaskFor:)
성공적으로 복사된 경우에만 처리하지 말고 모든 완료 결과에서 모양을 복원하세요. 모델 변경은 앱에서 실제로 구현한 작업에 따라 조건부로 처리하세요. 완료 콜백의 지점은 화면 좌표이므로 SwiftUI 대상의 로컬 경계와 직접 비교하지 마세요. draggingSession(_:endedAt:operation:)
기존 SwiftUI .draggable 뒤에 투명한 네이티브 뷰를 배치해도 NSDraggingSource를 따른다는 이유만으로 해당 세션의 소스가 되지는 않습니다. 세션을 시작할 때 전달된 객체가 소스입니다. 시작과 완료를 한 소유자가 담당하도록 하고, 네이티브 드래그 표면을 도형이 실제로 상호작용하는 영역으로 제한하세요.
마우스 업 모니터링이나 프로바이더의 할당 해제에 의존하지 않는 이유
앱 전체의 마우스 업 모니터는 입력 전달을 관찰하는데, 이는 드래그 완료와 다른 경계입니다. Apple 문서에 따르면 로컬 이벤트 모니터는 중첩된 추적 루프에서 소비된 이벤트를 받지 않습니다. 이러한 제한 때문에 모니터는 소스의 문서화된 완료 콜백을 대체하기에 적합하지 않습니다. addLocalMonitorForEvents(matching:handler:)
마찬가지로 항목 프로바이더의 deinit은 객체의 수명을 나타냅니다. Swift의 디이니셜라이저는 인스턴스가 할당 해제되기 직전에 호출됩니다. 이 계약만으로는 드래그가 끝난 시점을 알 수 없습니다. 프로바이더가 파괴되는 시점을 완료 신호로 사용하면 UI 상태가 드래그 수명 주기 대신 참조 소유권에 종속됩니다. Deinitialization
시스템 드래그와 함께 두 번째 DragGesture를 추가하는 것도 개념적으로 같은 문제를 일으킵니다. 해당 제스처의 상태는 그 제스처에 속합니다. 로컬 제스처가 상호작용을 소유한다면 @GestureState를 사용하고, 시스템 드래그가 소유한다면 드래그 소스의 세션 콜백을 사용하세요. GestureState
완료 매트릭스로 수정 사항 확인하기
합성 원을 사용해 각 경로를 따로 확인하세요. 아래 내용은 구현에서 기대되는 동작이며, 실제 테스트 결과를 보고한 것은 아닙니다.
| 상호작용 | 기대되는 소스 피드백 | 기대되는 대상 결과 |
|---|---|---|
| 수신 영역 안에 원을 놓음 | 원래 모양으로 돌아옴 | 개수가 한 번 증가함 |
| 창의 빈 공간에 놓음 | 원래 모양으로 돌아옴 | 개수에 변동이 없음 |
| 드래그 중 수신 영역에서 벗어남 | 흐린 상태 유지 | 대상 강조 표시가 해제됨 |
| 다시 들어가 유효한 드롭을 완료함 | 완료 시 원래 모양으로 돌아옴 | 개수가 한 번 증가함 |
| 수락하는 대상 없이 앱 창 바깥에 놓음 | 원래 모양으로 돌아옴 | 개수에 변동이 없음 |
| 진행 중인 드래그를 취소함 | 원래 모양으로 돌아옴 | 개수에 변동이 없음 |
| 실패한 드래그를 여러 번 반복함 | 각 세션이 정상적으로 복원됨 | 수락된 복사본이 누적되지 않음 |
소스가 계속 흐리게 표시되면 먼저 완료 관찰자가 실제 드래그 소스를 감싸고 있는지, 소스가 계속 존재하는지 확인하세요. 대상 강조 표시만 남는다면 대상의 진입 및 이탈 처리와 공통 정리 로직을 살펴보세요. 거부된 드롭에서 수락 개수가 바뀐다면 시각적 복원과 분리해 유효성 검사 및 모델 변경을 검토하세요.
지속적으로 적용되는 해결 방법은 완료 소유권을 명확히 하는 것입니다. 소스는 세션 전체의 피드백을 복원하고, 대상은 자체 호버 상태와 수락된 콘텐츠를 처리하며, 전송 정리는 문서화된 수명 주기를 따릅니다. 책임을 이렇게 분리하면 모든 대상 바깥에 놓아도 대상 콜백을 기다리는 시각 상태가 남지 않습니다.
출처
onDragSessionUpdated(_:) — Apple
DragSession.Phase — Apple
DragSession.Phase.ended(_:) — Apple
draggable(_:) — Apple
DropDelegate — Apple
GestureState — Apple
NSViewRepresentable — Apple
init(pasteboardWriter:) — Apple
setDraggingFrame(_:contents:) — Apple
Deinitialization — Swift.org