Mac 항목 목록에서는 선택을 SwiftUI List에 바인딩하고, 선택을 인식하는 컨텍스트 메뉴를 통해 기본 동작을 제공하세요. 한 번 클릭하면 행이 선택되고, 두 번 클릭하면 열립니다. 선택 상태와 표시 상태를 분리하세요.
이 튜토리얼은 합성된 메모리 내 항목 세 개를 사용해 macOS 앱에서 이 상호작용을 구현합니다. 또한 사용자 지정 SwiftUI 표면과 AppKit 뷰의 경계를 설명하므로, 여러 계층이 같은 클릭을 처리하려고 경합하지 않도록 적절한 방식을 선택할 수 있습니다.
선택 가능한 컨테이너의 기본 동작 사용하기
Apple은 macOS에서 선택 가능한 컨테이너의 단일 클릭 선택과 두 번 클릭 기본 동작을 명시적으로 문서화합니다. 지원되는 SwiftUI 진입점은 contextMenu(forSelectionType:menu:primaryAction:)이며, macOS 13부터 사용할 수 있습니다. 이를 List나 Table처럼 선택을 지원하는 컨테이너에 연결하세요. Apple의 선택 인식 컨텍스트 메뉴 문서.
서로 다른 책임은 세 가지입니다. 모델은 각 항목을 식별하고, 목록은 바인딩을 통해 선택 상태를 유지하며, 기본 동작 처리기는 열기가 무엇을 의미하는지 결정합니다. 이 예시에서는 열기 동작으로 항목 상세 시트를 표시합니다. 항목을 선택하면 강조 표시된 행과 선택 레이블만 바뀝니다.
상호작용을 담당하는 뷰에 맞춰 구현 방식을 선택하세요.
| 인터페이스 | 적절한 시작점 | 계속 담당해야 하는 부분 |
|---|---|---|
| macOS 13 이상에서 사용하는 SwiftUI 항목 목록 또는 테이블 | 선택 바인딩과 선택 인식 기본 동작 | 동작 ID 확인 및 해당 콘텐츠 표시 |
| 사용자 지정 SwiftUI 타일 또는 도형 | 명시적인 제스처 조합 | 선택 모양, 활성화 정책 및 기타 입력 경로 |
| SwiftUI에 포함된 AppKit 테이블 | NSTableView 선택 및 doubleAction |
선택과 활성화를 SwiftUI 상태에 연결 |
| 사용자 지정 AppKit 드로잉 표면 | 자체 마우스 이벤트 처리 | 히트 테스트, 항목 식별 및 클릭 시퀀스 정책 |
첫 번째 방식이 여기서 다루는 작업에 직접 들어맞습니다. 사용자 지정 방식은 인터페이스에 실제로 다른 표면이 필요한 경우 유용하지만, 더 많은 상호작용 동작을 직접 구현해야 합니다. Apple은 기본이 되는 대안을 제스처 조합, AppKit 테이블의 두 번 클릭 동작, 마우스 누름 처리에서 설명합니다.
단일 선택 예제 완성하기
Xcode에서 SwiftUI 인터페이스를 사용하는 macOS 앱 프로젝트를 만들고 배포 대상을 macOS 13 이상으로 설정하세요. 생성된 앱 및 콘텐츠 뷰 선언을 다음 단일 파일로 바꾸세요. 타깃에는 @main 앱 선언을 하나만 남겨 두세요.
프로그램은 고정된 정수 ID, 색이 있는 원 세 개, 로컬 상태를 사용합니다. 파일을 읽거나 외부 리소스를 열지 않습니다. 도구 막대 스타일의 Open 버튼과 컨텍스트 메뉴의 Open 명령은 두 번 클릭 활성화와 같은 함수를 호출합니다.
import SwiftUI
struct SampleItem: Identifiable {
let id: Int
let name: String
let tint: Color
}
@main
struct ClickSelectionDemoApp: App {
var body: some Scene {
WindowGroup {
ItemBrowser()
}
}
}
struct ItemBrowser: View {
private let items = [
SampleItem(id: 1, name: "Circle A", tint: .blue),
SampleItem(id: 2, name: "Circle B", tint: .orange),
SampleItem(id: 3, name: "Circle C", tint: .purple)
]
@State private var selectedID: SampleItem.ID? = nil
@State private var openedItem: SampleItem? = nil
@State private var message = "Select an item."
private var selectedItem: SampleItem? {
items.first { $0.id == selectedID }
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(selectedItem.map {
"Selected: \($0.name)"
} ?? "No selection")
Spacer()
Button("Open") {
if let id = selectedID {
openItems([id])
}
}
.disabled(selectedItem == nil)
}
List(items, selection: $selectedID) { item in
HStack(spacing: 10) {
Circle()
.fill(item.tint)
.frame(width: 18, height: 18)
Text(item.name)
}
.padding(.vertical, 4)
}
.contextMenu(forSelectionType: SampleItem.ID.self) { ids in
if ids.count == 1 {
Button("Open") {
openItems(ids)
}
}
} primaryAction: { ids in
openItems(ids)
}
Text(message)
.font(.caption)
}
.padding()
.frame(minWidth: 360, minHeight: 280)
.sheet(item: $openedItem) { item in
VStack(spacing: 16) {
Circle()
.fill(item.tint)
.frame(width: 80, height: 80)
Text(item.name)
.font(.title2)
Button("Close") {
openedItem = nil
}
}
.padding(32)
.frame(minWidth: 260, minHeight: 220)
}
}
private func openItems(_ ids: Set<SampleItem.ID>) {
guard ids.count == 1, let id = ids.first else {
message = "Choose exactly one item to open."
return
}
guard let item = items.first(where: { $0.id == id }) else {
message = "That item is no longer available."
return
}
openedItem = item
message = "Opened: \(item.name)"
}
}
선언과 참조는 확인했지만, 사용 가능한 환경에 Swift 툴체인이나 macOS GUI가 없어 여기서는 이 예제를 컴파일하거나 실행하지 않았습니다. Xcode에서 빌드한 뒤 아래 상호작용을 확인하세요.
식별, 선택, 열기를 구분하기
SampleItem은 Swift의 Identifiable 프로토콜을 준수합니다. 고정된 id는 뷰가 업데이트되어도 같은 항목을 나타냅니다. Apple은 이 프로토콜을 안정적인 식별성을 기준으로 정의하며, 표시 이름은 별도의 속성입니다. 더 큰 앱에서는 이름이나 모양이 바뀌어도 항목의 ID를 유지하세요. Apple의 Identifiable 문서.
옵셔널 selectedID은 단일 선택 목록을 만듭니다. 대신 ID 집합을 사용하면 여러 항목을 선택할 수 있습니다. SwiftUI의 목록 문서는 이 두 가지 바인딩 형식과 컨테이너가 제공하는 선택 표시를 설명합니다. Apple의 List 문서.
openedItem은 표시 상태를 별도로 제어합니다. 항목을 대입하면 시트가 표시되고, nil를 대입하면 닫힙니다. 이는 문서화된 항목 기반 시트 API를 따릅니다. 행을 선택해도 openedItem에는 값을 대입하지 않으므로, 이 구현에서는 선택만으로 시트가 표시되지 않습니다. Apple의 항목 기반 시트 문서.
동작에 전달된 ID 확인하기
컨텍스트 메뉴 API는 컨테이너의 식별자 타입을 사용하며, 동작은 ID 집합을 전달받습니다. 따라서 이 예제에서는 목록 선택과 일치하도록 SampleItem.ID.self를 사용합니다. 별도로 읽은 선택 값을 대신 사용하지 않고 콜백 인수를 확인합니다. Apple의 컨텍스트 메뉴 매개변수 문서.
처리기는 의도적으로 ID를 정확히 하나만 받습니다. 집합이 비어 있으면 메시지를 표시하고, ID를 찾을 수 없어도 다른 메시지를 표시합니다. 나중에 여러 항목 선택을 활성화한다면 이 검사를 바꾸기 전에 열기 정책을 정하세요. 그룹 상세 뷰를 표시하거나, 별도 창을 열거나, 하나만 선택하도록 요구할 수 있습니다. 임의의 첫 번째 요소를 가져오면 정책이 정해지지 않은 상태가 감춰집니다.
두 개의 탭 처리기에는 별도의 설계 결정이 필요한 이유
사용자 지정 타일은 자체 제스처가 필요할 수 있습니다. SwiftUI의 onTapGesture(count:perform:)은 필요한 클릭 또는 탭 횟수를 받습니다. 하지만 문서에는 뷰에 독립적인 수정자 두 개를 붙이면 단일 클릭과 두 번 클릭을 서로 배타적으로 처리하는 계약이 만들어진다고 나와 있지 않습니다. Apple의 탭 동작 문서.
먼저 두 번 클릭할 때 선택이 무엇을 의미하는지 결정하세요. 일반적인 항목 브라우저에서는 항목을 연 다음이 아니라 열기 전에 선택하는 편이 유용하며, 컨테이너 기반 방식과도 일관됩니다. 시퀀스가 두 번 클릭으로 이어지지 않을 때에만 단일 클릭 동작을 수행해야 한다는 별도 요구사항이 있다면 조정 과정이 필요합니다.
사용자 지정 표면에는 SwiftUI의 exclusively(before:)가 있습니다. 이 제스처에서는 하나의 구성 요소 제스처만 성공하며, 첫 번째 제스처가 우선합니다. 다음 코드는 제스처 표현식 조각이며 목록 예제를 완전히 대체하지 않습니다. Apple의 배타적 제스처 문서.
TapGesture(count: 2)
.exclusively(before: TapGesture(count: 1))
이 코드는 두 번 클릭 인식을 우선합니다. 조합된 제스처를 사용자 지정 항목에 연결하고, 선택 및 열기 정책에 따라 결과를 처리하세요. TapGesture는 필요한 탭 횟수에 따른 인식을 문서화합니다. Apple의 TapGesture 문서.
이 정책에는 시간상 결과가 따릅니다. 시퀀스가 단일 클릭인지 배타적으로 판단하려면 두 번 클릭이 아니라는 점을 확인해야 합니다. 이는 상호작용 요구사항에서 생기는 결과이며, SwiftUI에 고정된 지연 시간이 문서화되어 있다는 뜻은 아닙니다. 단일 클릭이 즉시 완료되면서 동시에 두 번 클릭 시퀀스 중에는 절대 발생하지 않도록 보장한다고 약속하지 마세요.
사용자 지정 타일에서는 클릭할 수 있는 영역도 정의하세요. contentShape(Rectangle())은 패딩된 직사각형 영역을 히트 테스트에 포함할 수 있지만, 제스처 우선순위를 정하지는 않습니다. 클릭이 누락되는 원인을 진단할 때 이 두 문제를 분리하세요. Apple의 콘텐츠 셰이프 문서.
AppKit이 관련될 때 이벤트 소유권 유지하기
기존 AppKit 테이블이 행을 소유한다면 해당 테이블이 지원하는 활성화 방식을 사용하세요. NSTableView.doubleAction은 두 번 클릭할 때 대상에 선택기를 보냅니다. Apple은 활성화 위치를 판단하는 clickedRow과 clickedColumn도 문서화하며, 여기에는 열 머리글도 포함됩니다. 열기 전에 위치가 항목을 가리키는지 확인하세요. Apple의 doubleAction 문서.
사용자 지정 AppKit 표면에서는 mouseDown(with:)가 왼쪽 버튼 누름 이벤트를 받으며, NSEvent.clickCount는 마우스 누름 또는 놓기 이벤트와 연관된 클릭 횟수를 보고합니다. 관련 없는 이벤트에서 이 속성을 읽는 것은 문서화된 계약 범위 밖이며 예외가 발생할 수 있습니다. 적절한 마우스 이벤트를 처리하는 뷰 안에서 해석하세요. Apple의 마우스 누름 문서, Apple의 클릭 횟수 문서.
SwiftUI에 AppKit 뷰를 포함할 때는 NSViewRepresentable을 사용하세요. makeNSView에서 뷰를 만들고, updateNSView에서 현재 구성을 동기화하세요. 대상 동작이나 델리게이트 통신을 연결해야 한다면 코디네이터를 사용하세요. Apple은 생성, 업데이트, 조정, 해제를 이 통합 과정의 일부로 문서화합니다. Apple의 NSViewRepresentable 문서.
마우스 이벤트를 받는 오버레이는 클릭을 처리하는 뷰를 바꿀 수 있습니다. AppKit은 히트 테스트를 사용해 마우스 누름 이벤트를 받을 뷰를 결정하므로, 이벤트를 가로채는 오버레이를 보이지 않는 관찰자로 취급해서는 안 됩니다. 이 작업에서는 선택 가능한 컨테이너나 항목 상호작용을 실제로 소유한 사용자 지정 표면 안에서 인식 처리하세요. Apple의 히트 테스트 문서.
선택과 열기를 별도의 결과로 확인하기
예제를 빌드한 뒤 다음 순서로 확인하세요.
Circle A를 한 번 클릭합니다. 해당 행이 선택되고 선택 레이블에 이름이 표시되며, 시트는 나타나지 않아야 합니다.Circle B를 두 번 클릭합니다. 상세 시트에Circle B가 표시되는지 확인합니다.- 시트를 닫고
Circle C를 한 번 클릭합니다. 다른 시트를 열지 않고 선택만 바뀌는지 확인합니다. - 화면에 보이는
Open버튼을 활성화합니다. 같은 처리기를 통해 선택된 항목이 열리는지 확인합니다. - 행의 컨텍스트 메뉴에서
Open명령을 실행하고, 해당 명령이 여는 항목을 확인합니다. - 목록의 사용하지 않는 공간을 한 번 클릭하고 두 번 클릭합니다. 관련 없는 항목이 열리지 않는지 확인합니다.
- 컨트롤, 드래그 또는 오버레이를 추가했다면 해당 영역에서도 확인을 반복합니다.
선택했을 때 예상치 않게 상세 화면이 열린다면 표시 상태를 대입하는 부분을 살펴보세요. 활성화했을 때 잘못된 항목이 열린다면 콜백 ID와 모델 조회를 확인하세요. 사용자 지정 타일의 일부에서 클릭이 누락된다면 제스처 우선순위를 바꾸기 전에 레이아웃과 히트 테스트 셰이프를 확인하세요. 이 점검은 상태, 동작 대상, 이벤트 전달을 한꺼번에 바꾸지 않고 각각 분리해 확인합니다.
일반적인 Mac 항목 목록이라면 선택 바인딩과 컨테이너의 기본 동작을 중심으로 구현하세요. 표면이나 상호작용에 필요할 때만 사용자 지정 인식을 추가하고, 모든 열기 경로가 하나의 명시적인 처리기를 통해 의도한 항목을 확인하도록 하세요.
출처
- contextMenu(forSelectionType:menu:primaryAction:) — Apple
- List — Apple
- Identifiable — Apple
- sheet(item:onDismiss:content:) — Apple
- onTapGesture(count:perform:) — Apple
- exclusively(before:) — Apple
- TapGesture — Apple
- contentShape(_:eoFill:) — Apple
- doubleAction — Apple
- mouseDown(with:) — Apple
- clickCount — Apple
- NSViewRepresentable — Apple
- hitTest(_:) — Apple