겹치는 드롭 영역이 있는 Mac 캔버스에서는 안정적인 상위 뷰 하나에 네이티브 드롭 처리를 배치하고, 현재 모델 상태를 기준으로 논리적 대상을 결정하세요. 드롭을 확정할 때 적격성을 다시 확인하세요. 콜백에서 거부했다고 해서 다른 뷰로 전달된다는 문서는 없습니다.
이 글에서는 합성 도형 레이블과 서로 겹치는 두 패널이 있는 앱의 상호작용을 구현합니다. 구체적인 목표는 위쪽 패널이 항목을 받지 않게 된 경우 아래쪽 패널이 드롭을 받을 수 있게 하는 것입니다. 드래그 중 적격성이 바뀌는 경우도 포함합니다. 아래의 라우팅 정책은 문서화된 콜백을 바탕으로 도출한 애플리케이션 설계입니다.
상태를 바꾸면 드래그가 다른 대상으로 안정적으로 넘어가지 않는 이유는 무엇인가요?
먼저 세 가지 질문을 구분하세요. 어떤 네이티브 뷰가 드래그를 받는지, 앱에서 어떤 논리적 패널을 적격한 대상으로 판단하는지, 최종 항목을 어떤 모델이 받는지입니다. 상태 변경은 두 번째 질문의 답을 바꿀 수 있지만, 첫 번째 질문에 대한 새로운 답을 정해 주지는 않습니다.
Apple은 SwiftUI의 validateDrop(info:)을 호환되는 드롭이 대상에 들어올 때 수행하는 유효성 검사로 설명합니다. dropUpdated(info:)은 유효성이 확인된 드롭이 수정된 뷰 안에서 이동할 때의 업데이트로 설명합니다. 두 설명 모두 소유 뷰의 관련 없는 상태가 바뀔 때마다 대상을 새로 탐색한다고 약속하지 않습니다. SwiftUI 유효성 검사 및 드롭 업데이트.
AppKit 경계에서는 더 명확하게 설명합니다. 한 번에 하나의 대상만 draggingUpdated(_:) 메시지 시퀀스를 받습니다. 겹치는 두 뷰가 모두 유효한 대상이면 이미지가 놓이거나 바깥으로 드래그될 때까지 가장 위에 있는 뷰가 메시지를 받습니다. 이는 AppKit의 라우팅을 설명하는 것이며, SwiftUI 형제 뷰 사이의 일반적인 우선순위 계약을 정하는 것은 아닙니다. AppKit 대상 업데이트.
기존 SwiftUI 드롭 액션에서 false을 반환하면 드롭이 실패했음을 알립니다. Apple은 이 반환값을 아래쪽 대상에 다시 시도하라는 요청으로 설명하지 않습니다. 마찬가지로 dropUpdated(info:)에서 nil을 반환하면 마지막으로 유효했던 제안을 유지하거나 기본적으로 복사 작업을 선택합니다. 이는 “통과”를 의미하지 않습니다. 클로저 기반 드롭 처리 및 제안 동작.
실제로는 대체 동작을 명시적인 애플리케이션 규칙으로 정해야 합니다. 단일 캔버스에서는 네이티브 드롭 수신자 하나가 여러 논리적 패널 중에서 선택하게 하면, 겹치는 자식 수신자 사이의 네이티브 전달에 의존하지 않아도 됩니다.
수정자를 붙이기 전에 소유권과 대체 동작을 정의하세요
이 예제에서 주황색 패널은 파란색 패널 뒤에 있습니다. 주황색 패널은 샘플 레이블을 항상 받습니다. 파란색 패널은 blueEnabled이 true일 때만 받습니다. 의도한 동작은 다음과 같습니다.
| 포인터 위치 | 파란색 패널이 드롭을 받음 | 논리적 수신자 |
|---|---|---|
| 주황색 패널만 있는 영역 | 어느 값이든 | 주황색 패널 |
| 겹치는 영역 | 예 | 파란색 패널 |
| 겹치는 영역 | 아니요 | 주황색 패널 |
| 파란색 패널만 있는 영역 | 아니요 | 없음 |
| 두 패널 바깥 | 어느 값이든 | 없음 |
이 표는 구현을 위한 판단 기준입니다. 특히 파란색 패널을 비활성화해도 파란색 패널 사각형 안의 모든 지점이 주황색 패널의 대상이 되는 것은 아닙니다. 포인터가 주황색 패널의 사각형 안에도 있을 때만 주황색 패널이 항목을 받습니다.
라우팅을 제어하는 상태는 시각적 패널보다 상위에 두세요. Apple은 상태 값이 필요한 가장 상위 뷰에 이를 배치하고 하위 뷰와 공유할 것을 권장합니다. 여기서는 상위 뷰 하나가 적격성, 기하 정보, 받은 항목을 포함하는 모델을 소유합니다. 자식 사각형은 독립적인 드롭 핸들러 대신 모양을 표시합니다. SwiftUI 상태 소유권.
드롭 액션이 실행될 때 해당 모델을 읽으세요. 진입 시점에 적격성 Boolean이나 대상 식별자를 저장해 두었다가 나중에도 이를 권위 있는 값처럼 사용하지 마세요. Swift 클로저는 생성된 문맥의 값과 참조를 보존할 수 있으며, 소유자가 교체된 뒤에도 보존된 참조가 이전 모델을 계속 가리킬 수 있습니다. 콜백이 실제로 어떤 객체를 캡처하는지 확인하세요. Swift 클로저 캡처.
소유자가 교체될 때는 별도의 정책을 선택하세요. 새 소유자를 기준으로 판단하거나 이전 소유자에 속한 작업을 거부할 수 있습니다. 유용한 거부 방식은 소유자에 리비전 번호를 붙이고, 교체 시 번호를 증가시키며, 지연된 작업을 확정하기 전에 캡처한 리비전과 비교하는 것입니다. 일시적인 “현재 강조된 대상” 값으로 소유권을 결정해서는 안 됩니다.
안정적인 SwiftUI 드롭 영역 하나 만들기
다음 전체 예제는 macOS 26을 배포 대상으로 하고 dropDestination(for:isEnabled:action:)을 포함한 SDK를 사용하는 macOS SwiftUI 앱이 필요합니다. Apple은 이 오버로드가 Transferable 유형, 활성화 여부, 디코딩된 항목과 DropSession을 받는 액션을 인자로 받는다고 설명합니다. 대상 영역은 수정된 뷰의 크기와 위치를 차지합니다. 최신 드롭 대상 API.
SwiftUI를 사용하는 새 macOS App 프로젝트를 만드세요. 생성된 앱 선언을 아래 코드 블록 두 개로 바꾸고, 한 Swift 파일에 차례로 넣으세요. 두 블록은 하나의 프로그램을 구성합니다. 메모리 내 문자열과 사각형만 사용합니다. 소스 레이블은 드롭 후에도 남으므로 이 예제는 복사 작업으로 동작합니다.
모델 및 결정적 라우팅
모델은 선택한 시각적 우선순위를 나타내기 위해 파란색 패널을 먼저 확인합니다. 렌더링과 라우팅 모두 같은 사각형을 사용합니다. 모델의 확정 메서드는 현재 적격성을 기준으로 수신자를 다시 계산하고, 추가하기 전에 샘플 항목 전체가 유효한지 검사합니다.
import SwiftUI
import Combine
@MainActor
final class CanvasModel: ObservableObject {
enum Zone: String {
case orange = "Orange"
case blue = "Blue"
}
let orangeRect = CGRect(x: 30, y: 30, width: 230, height: 170)
let blueRect = CGRect(x: 150, y: 100, width: 230, height: 170)
let samples = ["Circle", "Square"]
@Published var blueEnabled = true
@Published private(set) var orangeItems: [String] = []
@Published private(set) var blueItems: [String] = []
@Published private(set) var message = "Drag a sample onto a panel."
func recipient(at point: CGPoint) -> Zone? {
if blueEnabled && blueRect.contains(point) {
return .blue
}
if orangeRect.contains(point) {
return .orange
}
return nil
}
func receive(_ items: [String], at point: CGPoint, isLocal: Bool) {
guard isLocal,
!items.isEmpty,
items.allSatisfy({ samples.contains($0) }) else {
message = "No items added: use this window's sample labels."
return
}
guard let zone = recipient(at: point) else {
message = "No items added: this location has no eligible panel."
return
}
switch zone {
case .orange:
orangeItems.append(contentsOf: items)
case .blue:
blueItems.append(contentsOf: items)
}
message = "Added \(items.joined(separator: ", ")) to \(zone.rawValue)."
}
}
앱 및 드롭 수신자
상위 사각형이 유일한 드롭 수정자를 소유합니다. 어느 색상 패널에도 서로 경쟁하는 대상이 등록되지 않습니다. DropSession은 대상의 로컬 좌표계에 있는 위치와 앱 내에서 시작된 드래그를 식별하는 localSession을 제공합니다. 모델은 샘플 값도 확인합니다. 드롭 세션 정보.
@main
struct DropRoutingDemoApp: App {
var body: some Scene {
WindowGroup {
CanvasDemo()
}
}
}
@MainActor
struct CanvasDemo: View {
@StateObject private var model = CanvasModel()
@State private var scheduledChange: Task<Void, Never>?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
ForEach(model.samples, id: \.self) { item in
Text(item)
.padding(10)
.background(Color.gray.opacity(0.15))
.draggable(item)
}
}
Toggle("Blue accepts drops", isOn: $model.blueEnabled)
Button("Toggle blue in 3 seconds") {
scheduledChange?.cancel()
scheduledChange = Task { @MainActor in
do {
try await Task.sleep(for: .seconds(3))
} catch {
return
}
model.blueEnabled.toggle()
}
}
ZStack(alignment: .topLeading) {
Rectangle().fill(Color.gray.opacity(0.08))
panel("Orange", rect: model.orangeRect,
color: .orange, opacity: 0.55)
panel("Blue", rect: model.blueRect,
color: .blue,
opacity: model.blueEnabled ? 0.55 : 0.15)
}
.frame(width: 420, height: 300)
.dropDestination(for: String.self, isEnabled: true) {
items, session in
model.receive(items, at: session.location,
isLocal: session.localSession != nil)
}
Text(model.message)
Text("Orange: \(model.orangeItems.joined(separator: ", "))")
Text("Blue: \(model.blueItems.joined(separator: ", "))")
}
.padding(20)
.onDisappear {
scheduledChange?.cancel()
}
}
private func panel(_ title: String, rect: CGRect,
color: Color, opacity: Double) -> some View {
Rectangle()
.fill(color.opacity(opacity))
.overlay(Text(title))
.frame(width: rect.width, height: rect.height)
.position(x: rect.midX, y: rect.midY)
}
}
SwiftUI의 draggable(_:)은 사용자 입력으로 드래그를 시작하는 데 필요한 제스처를 등록합니다. 지연된 버튼은 예제의 적격성 상태만 변경합니다. 드래그 세션을 시작하거나 재시작하거나 이전하지 않습니다. SwiftUI 드래그 소스 동작.
Toggle blue in 3 seconds를 누른 다음 Circle을 겹치는 영역으로 드래그하고 파란색이 희미해질 때까지 그곳에 둡니다. 겹치는 영역 안에 놓으면 의도한 수신자는 주황색 패널입니다. 파란색 패널이 비활성화된 동안 파란색 패널만 있는 영역에 놓으면 두 항목 배열 모두 바뀌지 않고 상태에 이유가 표시됩니다.
이 수신자는 전체 라우팅 정책을 적용할 수 있도록 캔버스 전체에서 활성화된 상태를 유지합니다. 따라서 이 예제는 위치별 네이티브 거부 커서나 호버 강조를 제공하지 않습니다. 상태는 놓은 뒤 애플리케이션 수준의 수락 여부를 알려 줍니다. 제품에 그런 피드백이 필요하면 제안과 호버 처리를 별도로 추가하세요.
코드는 가져온 API 선언과 대조해 확인했습니다. 사용 가능한 환경에 Swift 툴체인이나 macOS 런타임이 없어 여기서 컴파일하거나 실행하지는 않았습니다.
호버 업데이트와 이전 배포 대상은 별도로 처리하세요
새 오버로드를 지원하지 않는 시스템도 지원한다면 사용 가능한 수신 API를 선택하되 같은 모델 및 기하 정책을 유지하세요. 기존 dropDestination(for:action:isTargeted:)은 디코딩된 항목과 로컬 지점을 전달합니다. Boolean 결과는 성공 여부를 나타냅니다. isTargeted 클로저는 네이티브 대상 영역에 들어오거나 벗어나는 상황을 설명하며, 이 설계에서는 캔버스 전체가 그 영역입니다. 포인터 아래의 논리적 패널을 식별하지는 않습니다. 기존 드롭 대상 계약.
명시적인 유효성 검사와 작업 제안을 사용하려면 onDrop(of:delegate:)로 DropDelegate를 등록하세요. 초기 적격성에는 validateDrop(info:)를, 현재 제안에는 dropUpdated(info:)를, 최종 유효성 검사와 포함에는 performDrop(info:)를 사용하세요. .forbidden을 반환하면 해당 위치에서 허용되지 않는 동작임을 나타냅니다. 다른 네이티브 수신자로 전달된다는 문서는 없습니다. 대리자 기반 대상 및 드롭 작업.
프로바이더를 사용할 때는 중요한 로딩 경계가 있습니다. DropInfo.itemProviders(for:)은 performDrop 중에만 유효합니다. 해당 메서드가 반환되기 전에 프로바이더 콘텐츠 로딩을 시작하세요. 완료는 나중에 도착할 수 있으므로 모델을 변경하기 전에 대상의 식별자, 리비전, 계속되는 적격성을 다시 확인하세요. 실패한 로드를 조용히 버리지 말고 거부 또는 로드 실패를 보고하세요. 프로바이더 접근 및 드롭 완료 요구 사항.
호버 표시를 위해 세션 정보를 받은 항목과 분리해 저장하세요. 최신 DropSession 문서는 단계 변경을 관찰하는 방법으로 onDropSessionUpdated(_:)을 안내합니다. 모델 상태가 바뀌면 포인터 업데이트가 새로 발생한다고 가정하지 마세요. 이미 유효한 호버 위치가 있다면 적격성이 바뀔 때 논리적 강조 대상도 다시 계산하세요. 최종 수락 시에는 여전히 라우팅 함수를 다시 호출해야 합니다. 드롭 세션 수명 주기.
캔버스에서 AppKit을 사용해야 하는 경우는 언제인가요?
포인터가 멈춰 있는 동안 문서화된 주기적 네이티브 업데이트가 필요한 상호작용이라면 AppKit 대상을 사용하세요. AppKit의 wantsPeriodicDraggingUpdates()이 이 동작을 제어합니다. false을 반환하면 업데이트가 마우스 이동이나 수정자 변경 때로 제한됩니다. 그렇지 않으면 아무것도 바뀌지 않아도 주기적 업데이트가 발생합니다. 이를 통해 포인터가 멈춘 드래그 중 현재 작업을 다시 평가하는 문서화된 방법을 사용할 수 있습니다. 주기적 드래그 업데이트.
이에 대응하는 구조도 캔버스 대상 하나를 사용합니다. 허용할 페이스트보드 유형을 등록하고, draggingEntered(_:) 및 draggingUpdated(_:)에서 논리적 패널을 결정하고, 놓을 때 수락 여부를 확인하며, performDragOperation(_:)에서 항목을 포함합니다. Apple은 하나의 대상 뷰 안에 있는 그래픽 또는 텍스트 영역에 따라 작업을 조정하는 방법을 구체적으로 설명합니다. AppKit 대상 수명 주기 및 위치에 따른 작업.
NSViewRepresentable로 대상을 임베드할 때 updateNSView(_:context:)에서 모델 참조, 적격성 설정, 콜백을 업데이트하세요. 초기 생성만으로는 이후의 소유자 변경을 동기화할 수 없습니다. Apple은 이 업데이트 메서드를 AppKit 뷰를 SwiftUI 상태에 맞추는 위치로 안내합니다. AppKit 뷰 업데이트.
소스 이벤트 경계도 유지하세요. AppKit의 beginDraggingSession(with:event:source:)는 드래그 항목과 시작 마우스 누름 이벤트로 세션을 시작합니다. 이는 대상 재지정 API가 아닙니다. 기존 SwiftUI 소스가 작업에 적합하다면 그대로 유지하세요. 소스 측 상호작용도 직접 제어해야 할 때만 AppKit 소스를 사용하세요. AppKit 드래그 시작.
소유권에 영향을 주는 지점에서 상태 변경을 확인하세요
지원하는 Mac 배포 버전에서 다음을 수동으로 확인하세요. 이 검사는 시각적 변화가 우연히 작동하는 것처럼 보이는 경우와 올바른 라우팅을 구분합니다.
- 파란색 패널이 활성화된 상태에서 겹치는 영역에 놓으면 파란색 패널의 배열에만 항목이 추가됩니다.
- 드래그 전에 파란색 패널을 비활성화하면 겹치는 영역의 항목은 주황색 패널로 라우팅됩니다.
- 포인터가 멈춘 드래그 중 파란색 패널을 비활성화하면 최종 수락은 비활성화된 상태를 사용합니다.
- 비활성화된 동안 파란색 패널만 있는 영역에 놓으면 두 배열 모두 바뀌지 않고 상태에 결과가 설명됩니다.
- 드래그 중 파란색 패널을 다시 활성화하면 확정 시 겹치는 영역의 항목이 파란색 패널로 라우팅됩니다.
- 드래그를 취소하면 두 배열 모두 바뀌지 않습니다.
- 앱에서 캔버스 소유자를 교체할 수 있다면 드래그 중 하나를 교체하고 선택한 소유자 교체 정책이 적용되는지 확인합니다.
- 구현이 프로바이더를 비동기로 로드한다면 완료 전에 소유자를 변경하고 리비전 검사와 사용자에게 표시되는 실패 보고가 작동하는지 확인합니다.
스크롤하거나 확대/축소하거나 크기를 조절할 수 있는 캔버스에서는 모든 패널의 경계와 드롭 위치를 하나의 좌표 공간으로 변환하도록 같은 설계를 확장하세요. 이 예제는 고정 사각형을 사용하므로 변환이 필요하지 않습니다. 변환된 레이아웃에 사각형의 숫자를 그대로 복사하면 라우팅 정책이 깨집니다.
안정적인 네이티브 수신자 하나와 현재 상태를 사용하는 라우팅 함수가 명확한 결과를 제공합니다. 가장 위에 있는 적격 패널이 항목을 받고, 적격한 아래쪽 패널은 겹치는 영역의 대체 수신자가 되며, 어느 패널도 덮지 않는 위치에서는 모델이 바뀌지 않습니다. 호버 피드백과 최종 수락에서 같은 정책을 사용하고, 최종 수락 시에는 자체 검사를 수행하세요.
출처
validateDrop(info:) — Apple.
dropUpdated(info:) — Apple.
draggingUpdated(_:) — Apple.
State — Apple.
Closures — Swift.org.
DropSession — Apple.
draggable(_:) — Apple.
onDrop(of:delegate:) — Apple.
DropOperation — Apple.
itemProviders(for:) — Apple.
performDrop(info:) — Apple.
wantsPeriodicDraggingUpdates() — Apple.
NSDraggingDestination — Apple.
updateNSView(_:context:) — Apple.