합성 카드가 네이티브 macOS 드래그 중 온전하게 표시되도록 하려면, 바깥 컨테이너에 하나의 드래그 소스를 두고 전체 카드를 미리보기로 렌더링하세요. 미리보기의 초기 프레임과 포인터 오프셋을 명시적으로 제어해야 한다면 AppKit을 사용하세요.

이 가이드는 Mac 앱에서 메모리에 있는 카드 하나를 두 패널 사이로 드래그하는 방법을 다룹니다. 실질적인 목표는 제스처 처리와 모델 업데이트의 책임을 명확히 유지하면서 카드의 모양, 텍스트, 간격, 정체성을 보존하는 것입니다.

드래그의 어느 부분을 바꿔야 하나요?

Apple은 전송되는 데이터와 드래그 중 표시되는 시각적 표현을 구분합니다. SwiftUI의 draggable(_:preview:)은 드래그가 시작될 때 미리보기 뷰를 렌더링합니다. 문서에 따르면 미리보기는 소스 뷰 위에 가운데 정렬됩니다. 소스 뷰 자체가 전송되는 것은 아닙니다. Apple의 draggable(_:preview:) 문서를 참조하세요.

필요한 제어 항목을 찾으려면 다음 판단표를 사용하세요:

원하는 동작 권장되는 시작점
카드의 레이블, 모양, 배경을 함께 유지 바깥 드래그 소스 하나와 완전한 사용자 지정 미리보기 하나
들어 올리는 전환 중 윤곽선 조정 contentShape(.dragPreview, shape)
카드의 어느 부분에서 드래그를 시작할 수 있는지 변경 contentShape(.interaction, shape)
카드를 다른 패널에 적용 페이로드를 검증하고 대상에서 모델 업데이트
초기 드래그 이미지 사각형과 포인터의 관계를 명시적으로 설정 NSDraggingItem을 사용하는 AppKit 소유 세션
네이티브 드롭 대상을 사용하지 않고 캔버스 안에서 요소를 연속 이동 로컬 DragGesture 상호작용

두 콘텐츠 모양의 차이가 중요합니다. Apple 문서에 따르면 .dragPreview는 미리보기 모양에 영향을 주고, .interaction은 히트 테스트를 제어합니다. 미리보기 윤곽선만 바꿔서는 드래그를 시작하는 영역이 넓어지지 않습니다. ContentShapeKinds.dragPreview를 참조하세요.

로컬 DragGesture는 제스처 시퀀스를 통해 드래그 동작을 전달합니다. 캔버스 위치를 업데이트하는 작업에는 이 방법을 선택하세요. 이 글에서 다루는 패널 간 네이티브 드롭 작업에는 draggable이 드래그 제스처를 설치하도록 하세요. Apple의 DragGesture 참조 문서와 드래그 소스 가이드를 참조하세요.

합성 카드 전체를 위한 미리보기 하나 만들기

함께 이동해야 하는 모든 요소를 포함하는 가장 작은 컨테이너에 드래그 수정자를 적용하세요. 원, 텍스트 두 줄, 둥근 배경으로 만든 카드라면 그 컨테이너는 카드 전체입니다. 자식 요소마다 별도의 드래그 소스를 붙이면 각각 다른 상호작용으로 정의됩니다.

소스와 미리보기에서 동일한 프레젠테이션 뷰를 재사용하세요. 이 뷰에 명시적인 크기와 자체 배경을 지정하고, 바깥 패널에서 상속되는 스타일에 의존하지 마세요. 이는 렌더링된 미리보기의 동작에서 도출한 디자인 권장 사항입니다. 독립적인 프레젠테이션을 사용하면 두 모양을 일치시키기 쉽습니다.

완전한 SwiftUI 예제

배포 대상이 macOS 13 이상인 macOS SwiftUI 앱을 만드세요. 생성된 앱 진입점과 콘텐츠 뷰를 이 단일 파일로 교체하고, 대상에 @main 선언은 하나만 남겨 두세요. 여기서 사용하는 사용자 지정 미리보기 수정자와 대상 오버로드는 모두 macOS 13부터 사용할 수 있다고 문서에 나와 있습니다. draggable(_:preview:) 및 dropDestination(for:action:isTargeted:)를 참조하세요.

import Foundation
import SwiftUI

@main
struct IntactDragDemoApp: App {
    var body: some Scene {
        WindowGroup {
            BoardView()
        }
    }
}

private struct CardFace: View {
    var body: some View {
        HStack(spacing: 14) {
            Circle()
                .fill(Color.orange)
                .frame(width: 36, height: 36)

            VStack(alignment: .leading, spacing: 6) {
                Text("Tile A")
                    .font(.headline)
                Text("Circle and label")
                    .font(.caption)
            }
            .foregroundStyle(Color.white)
        }
        .padding(16)
        .frame(width: 220, height: 100, alignment: .leading)
        .background(Color.indigo)
        .clipShape(RoundedRectangle(cornerRadius: 16))
    }
}

private struct BoardView: View {
    @State private var token = UUID().uuidString
    @State private var isOnRight = false

    var body: some View {
        VStack(spacing: 20) {
            Text("Drag the card between panels")
                .font(.headline)

            HStack(spacing: 24) {
                panel(right: false)
                panel(right: true)
            }
        }
        .padding(24)
    }

    private func panel(right: Bool) -> some View {
        VStack(spacing: 16) {
            Text(right ? "Right" : "Left")
                .font(.headline)

            ZStack {
                if isOnRight == right {
                    CardFace()
                        .contentShape(
                            .interaction,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .contentShape(
                            .dragPreview,
                            RoundedRectangle(cornerRadius: 16)
                        )
                        .draggable(token) {
                            CardFace()
                        }
                } else {
                    Text("Drop here")
                        .foregroundStyle(.secondary)
                }
            }
            .frame(width: 240, height: 120)
        }
        .frame(width: 260, height: 180)
        .background(
            RoundedRectangle(cornerRadius: 20)
                .fill(Color.gray.opacity(0.12))
        )
        .contentShape(Rectangle())
        .dropDestination(for: String.self) { items, _ in
            guard items == [token] else {
                return false
            }
            isOnRight = right
            return true
        }
    }
}

이 예제는 합성 그래픽과 메모리에서 생성한 식별자만 사용합니다. String은 이미 Transferable을 준수하므로 사용자 지정 전송 표현이 필요하지 않습니다. Apple은 뷰를 드래그 소스로 만들기에서 이 기본 준수를 설명합니다.

예상되는 상호작용은 간단합니다. Tile A를 다른 패널로 드래그해 놓은 다음 다시 원래 패널로 드래그합니다. 완전한 CardFace가 이동하는 미리보기를 제공합니다. 대상은 일치하는 토큰 하나만 허용하고 단일 위치 값을 변경합니다. true를 반환하면 드롭 성공으로 처리되고, false을 반환하면 대상 액션의 문서화된 계약에 따라 거부됩니다.

이 예제는 macOS 앱 대상용으로 작성된 완전한 예제입니다. 연구 환경에 Swift 툴체인이나 macOS 런타임이 없어 여기서는 컴파일하거나 실행하지 않았습니다.

시각적 연속성과 모델 소유권을 분리하세요

예제에서는 허용된 드롭으로 isOnRight이 바뀔 때까지 원래 카드가 계속 표시됩니다. 패널 밖에 놓으면 모델은 바뀌지 않습니다. 이미 카드가 있는 패널에 놓으면 기존 위치가 다시 할당됩니다. 토큰이 일치하지 않거나 항목이 여러 개면 상태를 바꾸기 전에 거부됩니다.

이 동작은 드래그 시작 시 카드를 제거한 뒤 취소 시 다시 만들어야 하는 상황을 방지합니다. 미리보기는 시각적 연속성을 제공하고, 대상이 위치 변경을 담당합니다. 더 큰 보드에서는 안정적인 항목 식별자와 권위 있는 컬렉션 하나를 사용해 같은 패턴을 적용하세요.

Swift의 값 의미론은 페이로드를 전달해도 원본 모델이 자동으로 이동하지 않는 이유를 설명하는 데 도움이 됩니다. 구조체와 문자열은 값 의미론을 따르므로, 이를 할당하거나 전달한다고 해서 애플리케이션의 컬렉션 안에서 항목이 자동으로 옮겨지지는 않습니다. 모델 업데이트에서 그 이동을 명시해야 합니다. 구조체와 클래스를 참조하세요.

애플리케이션 수준의 이동과 네이티브 드래그 작업도 구분하세요. 이 예제는 드롭을 허용한 후 공유 보드 상태 하나를 변경합니다. 네이티브 이동 협상을 설정하거나 이동 커서를 보장하지는 않습니다.

macOS 26 이상에서 Apple은 지원 작업을 선언하는 DragConfiguration과 수행된 작업을 관찰하는 onDragSessionUpdated(_:)을 문서화합니다. 대상이 수행할 작업을 선택합니다. 네이티브 이동 후 소스에서 항목을 제거한다면, 동일한 공유 항목이 두 번 제거되지 않도록 대상 처리와 조율하세요. DragConfiguration 및 onDragSessionUpdated(_:)을 참조하세요.

AppKit이 드래그 세션을 맡아야 하는 경우는 언제인가요?

카드를 집을 때 포인터가 카드 안의 같은 지점에 놓이는 것처럼, 명시적으로 배치된 드래그 이미지를 인수 조건에 포함해야 한다면 AppKit을 사용하세요. SwiftUI의 문서화된 사용자 지정 미리보기 수정자는 페이로드와 미리보기 뷰를 받으며, 미리보기를 가운데 정렬한다고 설명합니다. AppKit에서는 드래그 항목의 프레임을 직접 지정할 수 있습니다.

AppKit 구현은 다음 순서로 진행하세요:

  1. 소스 NSView가 실제 마우스 다운 이벤트를 받고, 상호작용이 드래그로 이어지는지 추적하게 합니다.
  2. 합성 카드 전체를 하나의 NSImage로 렌더링하고, 페이스트보드 작성자와 함께 NSDraggingItem 하나를 만듭니다.
  3. setDraggingFrame(_:contents:)으로 이미지와 초기 사각형을 설정합니다.
  4. 사각형을 시작 뷰의 좌표계로 표현한 다음, 시작 마우스 다운 이벤트와 NSDraggingSource를 사용해 beginDraggingSession(with:event:source:)을 호출합니다.
  5. 대상에서 드롭을 검증하고 적용하게 합니다. 세션 정리에는 소스의 완료 콜백을 사용합니다.

Apple은 시작 이벤트의 마우스 위치가 드래그 아이콘의 오프셋을 결정하며, 각 항목의 초기 프레임은 소스 뷰의 좌표를 사용해야 한다고 명시합니다. 카드 사각형과 이벤트 위치를 일관된 좌표계로 유지하세요. beginDraggingSession(with:event:source:)을 참조하세요.

setDraggingFrame(_:contents:)은 이미지 하나로 표현되는 드래그 항목을 위한 문서화된 편의 메서드입니다. 합성 카드에는 여러 그래픽 요소가 있을 수 있지만, 여전히 이미지 하나로 표현할 수 있습니다. setDraggingFrame(_:contents:)를 참조하세요.

이벤트와 세션의 경계를 유지하세요

카드의 상호작용 영역에는 드래그 시작 주체를 하나만 선택하세요. 구현 규칙으로, 동일한 영역에서 동작 중인 SwiftUI draggable 소스 위에 AppKit 세션 시작 코드를 겹쳐 두지 마세요. 둘 다 상호작용을 맡으려 하기 때문입니다. 제스처 소유권이 불분명한 문제를 보완하려고 마우스 이벤트를 임의로 만들지 마세요.

NSViewRepresentable을 사용해 AppKit 소스를 임베드할 수 있습니다. 네이티브 이벤트 처리는 해당 NSView에 두고, 허용된 모델 변경은 바인딩이나 코디네이터를 통해 전달하세요. SwiftUI가 표현된 뷰의 레이아웃을 제어합니다. Apple은 관리되는 뷰의 frame이나 bounds을 직접 설정하지 말라고 명시합니다. 드래그 항목의 이미지 사각형은 별개의 문제입니다. NSViewRepresentable을 참조하세요.

NSDraggingItem을 보관해 나중에 변경하지 마세요. Apple은 이 객체의 수명이 제한적이라고 문서화합니다. 세션이 생성된 뒤에는 세션 열거를 통해 드래그 항목을 변경하고, 열거된 각 항목은 해당 콜백 안에서만 사용하세요. 드래그가 대상 위에 있는 동안에는 대상 쪽 변경이 소스 쪽 이미지 변경을 덮어쓸 수도 있습니다. NSDraggingItem 및 enumerateDraggingItems(options:for:classes:searchOptions:using:)을 참조하세요.

미리보기가 여전히 잘못 표시될 때는 어떻게 진단하나요?

문제가 나타나는 단계에서 원인을 확인하세요:

증상 먼저 확인할 항목
원이나 텍스트만 이동함 카드 전체 컨테이너에 드래그 소스를 하나만 둡니다.
미리보기 크기가 카드와 다름 동일한 프레젠테이션 뷰와 명시적 크기를 재사용합니다.
집어 올릴 때의 윤곽선이 이동 이미지와 다름 .dragPreview와 사용자 지정 미리보기 클로저를 따로 확인합니다.
빈 공간에서도 예기치 않게 드래그가 시작됨 .interaction과 소스 컨테이너의 경계를 검토합니다.
AppKit 이미지가 집는 순간 튀어 움직임 시작 마우스 다운 이벤트와 소스 뷰 좌표를 확인합니다.
나중에 AppKit 이미지를 바꿔도 반영되지 않음 세션 열거 안에서 변경합니다.
취소 후 카드가 사라짐 모델 변경을 드롭 허용 처리로 옮기고 정리 로직을 검토합니다.

취소, 허용된 드롭, 원래 패널에 다시 놓기, 거부되는 페이로드를 각각 확인하세요. 중요한 불변 조건은 하나의 모델 위치가 계속 기준이 되는 것입니다. 완전한 SwiftUI 미리보기부터 시작하고, 필요한 이미지 배치에 명시적인 프레임과 이벤트 제어가 필요할 때만 AppKit이 소스를 맡도록 하세요.

출처