부모의 드래그를 simultaneousGesture(_:including:)로 연결해 자식 상호작용과 함께 참여하도록 하세요. 클릭 동작은 자식 버튼에 두고, 드래그에는 이동 임계값을 설정하며, 드래그와 선택이 함께 일어날 수 있는지 명시적으로 결정하세요.
이 안내에서는 합성된 항목 버튼 세 개가 들어 있는 이동 가능한 패널을 만듭니다. 독자가 구현할 동작은 구체적입니다. 항목을 클릭하면 선택되고, 패널을 드래그하면 같은 Mac 앱 안에서 위치가 바뀝니다. 이 예제에서는 선택과 이동이 함께 일어날 수 있습니다. 별도의 핸들을 사용하는 변형에서는 두 상호작용 영역을 분리합니다.
제스처 우선순위를 바꾸기 전에 상호작용 규칙을 정하세요
Apple은 simultaneousGesture(_:including:)가 기존 제스처와 같은 우선순위에 제스처를 추가한다고 설명합니다. Apple의 예제에서는 자식의 탭 핸들러와 이를 감싸는 컨테이너의 탭 핸들러가 클릭에 모두 반응합니다. 부모가 자식보다 의도적으로 우선하지 않으면서 함께 참여해야 할 때 사용할 수 있는 합성 방식입니다. Apple의 동시 제스처 문서
그러나 동시 인식만으로는 “드래그할 때 모든 자식 동작을 취소해야 한다”는 규칙을 표현할 수 없습니다. 또한 Apple의 예제는 모든 버튼 스타일과 컨테이너가 버튼과 드래그를 똑같이 판정한다는 점을 보여 주지 않습니다. 제스처 참여 여부와 앱의 동작 규칙은 별도로 결정하세요.
이 패널에서 부모는 패널의 오프셋과 선택된 항목을 소유합니다. 각 자식은 클로저를 통해 선택을 요청하는 버튼을 소유합니다. 같은 포인터 상호작용으로 패널이 움직이더라도 선택 요청은 유효한 것으로 처리됩니다. 이동 중에는 활성화되지 않아야 한다면, 문서화되지 않은 콜백 순서에 기대는 대신 아래의 별도 핸들 변형을 사용하세요.
자식 동작에는 Button을 사용하세요. Apple은 표준 접근성 동작을 포함한 버튼 동작이 필요한 상호작용에는 버튼을 사용할 것을 명시적으로 권장합니다. 사용자 지정 도형을 버튼 레이블로 사용할 수 있으므로, 이를 단순한 탭 제스처로 바꿀 필요는 없습니다. Apple의 제스처 문서
자식 버튼이 있는 완전한 이동 가능 패널 만들기
Xcode에서 macOS SwiftUI 앱을 만들고, 배포 대상 버전을 macOS 14 이상으로 설정한 다음, 생성된 앱과 콘텐츠 뷰를 아래의 단일 파일로 교체하세요. 타깃에는 앱 진입점을 하나만 남겨 두세요. 여기서 사용하는 최신 coordinateSpace(_:) 수정자를 지원하려면 macOS 14가 필요합니다. Apple의 좌표 공간 문서
모든 항목과 상태는 메모리에 있습니다. 이 예제에서는 버튼을 포함한 패널 전체를 고정된 작업 영역 안에서 이동합니다. 항목을 옮기거나 앱 윈도우를 이동하는 기능은 구현하지 않습니다.
import SwiftUI
@main
struct GesturePanelApp: App {
var body: some Scene {
WindowGroup {
PanelDemo()
}
}
}
private struct DemoItem: Identifiable {
let id: Int
let title: String
let color: Color
}
private struct ItemTarget: View {
let item: DemoItem
let isSelected: Bool
let select: () -> Void
var body: some View {
Button(action: select) {
HStack(spacing: 10) {
Circle()
.fill(item.color)
.frame(width: 18, height: 18)
Text(item.title)
Spacer()
if isSelected {
Text("Selected")
.font(.caption)
}
}
.padding(12)
.frame(width: 220)
.background(
isSelected
? Color.accentColor.opacity(0.18)
: Color.gray.opacity(0.12)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private struct PanelDemo: View {
private let items = [
DemoItem(id: 1, title: "Item A", color: .blue),
DemoItem(id: 2, title: "Item B", color: .orange),
DemoItem(id: 3, title: "Item C", color: .purple)
]
@State private var selectedID: Int? = nil
@State private var committedOffset: CGSize = .zero
@GestureState private var liveTranslation: CGSize = .zero
private var pan: some Gesture {
DragGesture(
minimumDistance: 8,
coordinateSpace: .named("stage")
)
.updating($liveTranslation) { value, translation, _ in
translation = value.translation
}
.onEnded { value in
committedOffset = CGSize(
width: committedOffset.width + value.translation.width,
height: committedOffset.height + value.translation.height
)
}
}
var body: some View {
VStack(spacing: 12) {
Text("Click an item or drag the panel")
ZStack {
Color.gray.opacity(0.06)
VStack(spacing: 10) {
Text("Drag panel")
.font(.headline)
.frame(width: 244, height: 32)
.contentShape(Rectangle())
ForEach(items) { item in
ItemTarget(
item: item,
isSelected: selectedID == item.id,
select: { selectedID = item.id }
)
}
}
.padding(16)
.background(Color.gray.opacity(0.15))
.contentShape(Rectangle())
.simultaneousGesture(pan, including: .all)
.offset(
x: committedOffset.width + liveTranslation.width,
y: committedOffset.height + liveTranslation.height
)
}
.frame(width: 640, height: 420)
.coordinateSpace(.named("stage"))
Button("Reset panel") {
committedOffset = .zero
}
}
.padding(20)
}
}
이 프로그램은 가져온 API 문서를 기준으로 검토했지만, 이 환경에는 Swift 컴파일러나 macOS UI 런타임이 없어 컴파일하거나 직접 조작해 테스트하지 않았습니다. Xcode에서 빌드하고 지원하려는 macOS 버전에서 아래 항목을 확인하세요.
드래그에 0이 아닌 임계값을 두는 이유
DragGesture는 제스처가 인식되기 전에 필요한 최소 이동 거리를 제공합니다. 예시 값 8은 부모가 드래그를 시작하기 전에 일반 클릭에 어느 정도의 이동 오차를 허용합니다. 이 값은 앱에서 정하는 선택 사항이며, Apple이 지정한 임계값도 클릭과 드래그를 구분하는 완전한 정책도 아닙니다. 거리를 0으로 설정하면 이러한 구분이 사라집니다. Apple의 DragGesture 문서
임시 이동과 확정된 이동을 분리하는 이유
제스처가 인식되는 동안 updating(_:body:)는 현재 이동량을 liveTranslation에 기록합니다. Apple 문서에 따르면 제스처가 끝나거나 취소되면 제스처 상태가 초기화됩니다. 이 예제는 onEnded에서만 최종 오프셋을 확정하므로, 취소되면 확정되지 않은 미리 보기 이동량이 폐기되고 오래된 임시 이동량이 남지 않습니다. Apple의 updating 문서
표시 위치는 확정된 오프셋에 현재 이동량을 더한 값입니다. Apple은 translation를 드래그 시작점으로부터의 전체 이동량으로 정의합니다. 따라서 변경이 있을 때마다 이 값을 확정된 오프셋에 반복해서 더하면 이동량이 과도하게 누적됩니다. 예를 들어, 확정된 가로 오프셋이 40이고 현재 이동량이 12이면 표시 오프셋은 52이 됩니다. 드래그를 마치면 52가 한 번 확정됩니다. Apple의 translation 문서
이름이 지정된 좌표 공간은 이동하는 패널을 감싸는 고정 작업 영역에 속합니다. 따라서 이동량 계산에 명시적인 기준 영역을 사용할 수 있습니다. 자식 버튼은 패널 위치를 각자 따로 저장하지 않습니다.
선택 상태를 부모에 두는 이유
부모는 selectedID를 저장하고 현재 선택 상태와 선택 클로저를 각 자식에 전달합니다. 이는 상태가 필요한 가장 상위 뷰에 상태를 두고, 값이나 바인딩을 계층 아래로 전달하라는 Apple의 지침을 따릅니다. Apple의 State 문서
클로저는 현재 항목을 캡처하고 부모 상태 업데이트를 요청합니다. Swift에서는 클로저가 주변 문맥의 값을 캡처할 수 있으므로, 이 콜백을 구현하기 위해 AppKit 이벤트를 복사하거나 전달할 필요가 없습니다. The Swift Programming Language: Closures
히트 영역과 제스처 마스크 이해하기
contentShape(Rectangle())은 히트 테스트 도형을 정의합니다. 버튼 레이블에서는 패딩과 프레임 뒤에 배치해 의도한 직사각형 레이블 영역을 명확히 합니다. 부모에서는 패널의 상호작용 도형을 정의합니다. 이 도형은 인식기 우선순위를 설정하거나 두 제스처가 협력하도록 만들지 않습니다. Apple의 content shape 문서
제스처 수정자에 전달하는 마스크는 어떤 제스처를 활성 상태로 유지할지 결정합니다. 다음 옵션은 잘못 해석하기 쉽습니다. subviews는 추가한 부모 제스처가 비활성화된다는 뜻이지, 자식과 자동으로 협력한다는 뜻이 아닙니다. Apple의 GestureMask 문서
| 마스크 | 문서에 명시된 효과 | 이 경우의 적합성 |
|---|---|---|
.all |
추가한 제스처와 뷰 및 하위 뷰의 다른 제스처를 활성화합니다 | 함께 참여시키는 출발점 |
.gesture |
추가한 제스처를 활성화하고 하위 뷰 계층의 제스처를 비활성화합니다 | 자식 제스처를 유지하려는 목적과 충돌합니다 |
.subviews |
하위 뷰 제스처를 활성화하고 추가한 제스처를 비활성화합니다 | 부모 드래그가 비활성화됩니다 |
.none |
추가한 제스처를 포함해 하위 뷰 계층의 제스처를 비활성화합니다 | 의도한 상호작용이 비활성화됩니다 |
부모 드래그 콜백이 발생하지 않는 일반적인 해결책으로 highPriorityGesture을 사용하지 마세요. 문서에 명시된 목적은 연결된 제스처에 더 높은 우선순위를 부여하는 것입니다. Apple의 예제에서는 부모가 자식 탭보다 우선합니다. 이 우선순위가 의도한 동작일 때 선택하세요. Apple의 높은 우선순위 제스처 문서
활성화 영역을 분리해야 한다면 별도 드래그 핸들 사용하기
버튼과 드래그 영역을 공유하지 않아야 하는 패널이라면 같은 상태와 이동량 계산을 유지하고 pan을 헤더에만 연결하세요. 부모 패널에서 .simultaneousGesture(pan, including: .all)을 제거합니다.
위의 완전한 프로그램에서 기존 헤더 표현식을 이 코드 조각으로 바꾸세요. 이미 정의된 pan 속성을 사용하며, 다른 사전 조건은 바뀌지 않습니다.
Text("Drag panel")
.font(.headline)
.frame(width: 244, height: 32)
.contentShape(Rectangle())
.gesture(pan)
이제 헤더와 항목 버튼이 서로 다른 영역을 차지하며, 버튼의 조상에 연결된 패널 드래그 제스처도 사라집니다. 항목에서 시작하면 버튼 상호작용이 되고, 헤더에서 시작하면 패널을 이동할 수 있습니다. 상호작용 공유가 모호한 결과를 낳을 수 있을 때 유용한 설계입니다.
자식이 동작을 시작할 때는 네이티브 버튼을 유지하세요. Apple은 Button을 동작과 레이블로 설명하며, 여기서 합성된 색상 항목에 사용한 것처럼 사용자 지정 레이블 뷰도 허용합니다. 이를 직접 만든 포인터 핸들러로 바꾸면 컨트롤이 이미 제공하는 동작을 직접 다시 구현해야 합니다. Apple의 Button 문서
프레임워크 경계에서 AppKit 이벤트 소유권 유지하기
패널에 AppKit 뷰가 포함되어 있으면 SwiftUI 제스처 합성과 별개로 히트 테스트를 진단하세요. AppKit의 hitTest(_:)은 일치하는 하위 항목 중 가장 깊은 뷰를 반환하며, 윈도우가 마우스 다운 이벤트를 받을 대상을 결정하도록 돕습니다. 입력 좌표는 뷰의 슈퍼뷰 좌표로 표현됩니다. Apple의 AppKit 히트 테스트 문서
히트 테스트에서 부모를 반환하면 초기 대상이 바뀝니다. 자식과 부모가 동시에 인식하도록 하는 것과는 다릅니다. 마찬가지로 오버레이의 히트 테스트에서 nil을 반환하면 해당 오버레이가 후보에서 제외됩니다. 그렇다고 오버레이가 자식의 전체 상호작용을 수동으로 관찰하게 되는 것은 아닙니다.
AppKit 문서에 따르면 기본 mouseDown(with:) 구현은 메시지를 다음 응답자에게 전달합니다. 이는 응답자 체인의 동작이지, 컨트롤이 이벤트를 처리한 뒤 모든 조상이 이벤트 사본을 각각 받는다는 뜻은 아닙니다. 명시적 콜백이나 직접 소유한 인식기를 사용해 컨트롤 활성화와 패널 이동을 연결하세요. Apple의 mouse-down 문서
AppKit 제스처 인식기 두 개를 직접 소유하고 동시 인식이 필요하면 AppKit의 gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:)를 사용하세요. Apple 문서에 따르면 true을 반환하면 두 인식기가 함께 인식될 수 있습니다. false을 반환해도 다른 인식기의 델리게이트가 허용하면 상호 배제가 보장되지 않습니다. 이 정책은 의도한 인식기 쌍에만 적용하세요. Apple의 인식기 델리게이트 문서
간단한 동작 표로 상호작용 확인하기
앱에서 지원하는 정확한 버튼 스타일, 뷰 계층, macOS 버전에서 테스트하세요. 다음 항목은 인식 문제와 이동량 계산 문제를 구분하고, 공유 표면이 의도한 상호작용 규칙에 맞는지 확인하는 데 도움이 됩니다.
| 동작 | 확인할 내용 |
|---|---|
| 드래그하지 않고 각 항목 클릭 | 선택 상태가 바뀌고 패널은 움직이지 않습니다 |
| 임계값 이상으로 헤더 드래그 | 패널이 포인터를 따라 움직이고 최종 위치를 유지합니다 |
| 공유 영역 버전에서 항목을 드래그 | 부모가 움직이는지 확인하고, 버튼도 활성화되는지 확인한 뒤 허용 가능한 동작인지 판단합니다 |
| 연속으로 두 번 드래그 | 두 번째 드래그가 첫 번째의 확정 위치에서 시작하며 각 중간 이동량이 누적되지 않습니다 |
| 드래그 도중 중단 | 임시 이동량이 지워지고, 완료되지 않은 이동은 확정되지 않습니다 |
| 핸들 변형 사용 | 항목은 별도로 활성화되고, 헤더에서 이동이 시작됩니다 |
| 키보드 탐색으로 항목 버튼 활성화 | 포인터 드래그 없이 선택이 작동합니다 |
빈 패널 영역은 제대로 드래그되지만 항목 영역은 그렇지 않다면 자식 컨트롤과 제스처 합성을 살펴보세요. 클릭이 작동하지 않으면 제스처 마스크, 우선순위, 히트 영역을 확인하세요. 패널이 빨라지거나 갑자기 튄다면 인식기를 더 추가하기 전에 이동량 누적 방식과 좌표 공간을 확인하세요.
이 예제에서는 의도적으로 이동을 제한하지 않으므로 패널이 보이는 작업 영역 밖으로 나갈 수 있습니다. Reset panel 버튼으로 위치를 복원할 수 있습니다. 완성된 제한형 작업 공간을 만들려면 작업 영역과 패널 크기를 바탕으로 명시적인 위치 제약을 추가하세요. 소유권 구조는 그대로 유지하세요. 자식은 동작을 요청하고, 부모는 이동을 소유하며, 상호작용 규칙에 따라 두 인식 영역이 겹칠지 결정합니다.
출처
- simultaneousGesture(_:including:) — Apple
- Gestures — Apple
- coordinateSpace(_:) — Apple
- DragGesture — Apple
- updating(_:body:) — Apple
- translation — Apple
- State — Apple
- Closures — The Swift Programming Language, Swift project
- contentShape(_:eoFill:) — Apple
- GestureMask — Apple
- highPriorityGesture(_:including:) — Apple
- Button — Apple
- hitTest(_:) — Apple
- mouseDown(with:) — Apple
- gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:) — Apple