사용자 지정 Mac 표면에서 이름이 지정된 동작을 수행한다면 SwiftUI Button을 사용하세요. 캔버스 위치처럼 상호작용에 제스처 정보가 필요하다면 클릭 제스처를 사용하세요. 이 가이드에서는 각각을 어디에 사용하는지 작은 도형 편집기를 만들어 살펴봅니다.
Apple은 요소가 버튼으로 기능할 때는 버튼을 사용할 것을 명시적으로 권장합니다. 버튼은 어떤 뷰든 레이블로 사용할 수 있으므로, 사용자 지정 모양을 구현한다고 해서 상호작용 동작까지 대체할 필요는 없습니다. SwiftUI는 접근성 레이블과 힌트를 비롯한 표준 버튼 동작을 제공합니다. 제스처
표면에 필요한 상호작용은 무엇인가요?
Mac 앱 개발자라면 상호작용이 어떤 정보를 전달해야 하는지가 핵심 질문입니다. 원 추가라고 표시된 타일은 명령을 나타냅니다. 빈 드로잉 표면을 클릭해 원을 배치하는 것은 명령과 위치를 함께 나타냅니다. 입력이 추가되면 적절한 구현 방식도 달라집니다.
다음 의사 결정 가이드는 일반적인 데스크톱 도형 편집기에 Apple이 문서화한 구분을 적용한 것입니다. 이는 모든 인터페이스에 적용되는 규칙이 아니라 API를 바탕으로 한 구현 권장 사항입니다.
| 독자가 의도한 상호작용 | 시작점 | 이유 |
|---|---|---|
원 추가라고 표시된 사용자 지정 타일 클릭 |
사용자 지정 레이블이 있는 Button |
표면이 이름이 지정된 동작을 시작합니다. |
| 해당 타일에 독특한 배경과 눌림 모양 적용 | ButtonStyle |
표준 버튼 상호작용을 유지하면서 모양을 사용자 지정합니다. |
| 캔버스를 클릭해 해당 위치에 원 배치 | SpatialTapGesture |
결과에 상호작용 위치가 포함됩니다. |
| 좌표가 필요하지 않은 콘텐츠 뷰에서 두 번 클릭 인식 | onTapGesture(count: 2) |
수정자에 필요한 클릭 횟수를 지정할 수 있습니다. |
| AppKit 상호작용에서 특정 마우스 버튼 설정 | NSClickGestureRecognizer |
마우스 버튼과 클릭 횟수를 설정할 수 있습니다. |
Apple은 버튼을 레이블과 짝을 이룬 동작으로 문서화하고, ButtonStyle을 통한 모양 사용자 지정과 PrimitiveButtonStyle을 통한 상호작용 사용자 지정을 구분합니다. 제스처 API에는 클릭 횟수와 공간 결과가 별도로 문서화되어 있습니다. Button, onTapGesture(count:perform:), SpatialTapGesture, NSClickGestureRecognizer
따라서 사용자 지정 액션 타일은 버튼으로 시작하는 것이 좋습니다. 표면에 필요한 추가 입력이나 인식 요건을 구체적으로 파악했을 때 제스처를 사용하세요.
동작을 다시 구현하지 않고 사용자 지정 버튼을 만들려면 어떻게 해야 하나요?
명령은 버튼의 액션 클로저에, 그래픽은 레이블에 넣으세요. 표현 방식에는 ButtonStyle을 사용하세요. Apple 문서에 따르면 이 프로토콜은 플랫폼의 표준 상호작용 동작을 유지합니다. 의도적으로 상호작용 동작을 변경하려면 별도의 확장 지점인 PrimitiveButtonStyle을 사용합니다. ButtonStyle
아래 예제에는 서로 나란히 있는 두 표면이 있습니다. 사용자 지정 원 추가 버튼과 직사각형 배치 캔버스입니다. 둘 다 동일한 변경 함수를 호출합니다. 버튼은 예측 가능한 슬롯을 선택하고, 캔버스는 로컬 좌표를 전달합니다. 파일이나 외부 콘텐츠, 다른 앱을 추가하지 않고도 차이를 확인할 수 있습니다.
SwiftUI를 사용하는 macOS App 프로젝트를 만들고 배포 대상으로 macOS 13 이상을 설정하세요. SpatialTapGesture의 문서상 최소 버전입니다. 생성된 앱 및 콘텐츠 뷰 정의를 첫 번째 파일로 바꾸고, 두 번째 파일을 같은 타깃에 추가하세요. @main 앱 선언은 하나만 유지하세요. 코드는 검토했지만 macOS SDK 환경에서 컴파일하거나 실행하지는 않았습니다. SpatialTapGesture
전체 애플리케이션은 다음 두 파일로 구성됩니다. 먼저 ShapeSurfaceApp.swift입니다.
import SwiftUI
import Foundation
@main
struct ShapeSurfaceApp: App {
var body: some Scene {
WindowGroup {
ShapeSurfaceView()
}
}
}
private struct PlacedCircle: Identifiable {
let id = UUID()
let center: CGPoint
}
struct ShapeSurfaceView: View {
@State private var circles: [PlacedCircle] = []
private let limit = 6
private var isFull: Bool {
circles.count >= limit
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Button {
let x = 35 + CGFloat(circles.count) * 50
addCircle(at: CGPoint(x: x, y: 100))
} label: {
HStack(spacing: 12) {
Circle()
.fill(Color.blue)
.frame(width: 24, height: 24)
Text("Add circle")
}
.frame(width: 280, height: 64)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(SurfaceButtonStyle())
.keyboardShortcut("a", modifiers: [.command, .shift])
.disabled(isFull)
.opacity(isFull ? 0.4 : 1)
Text("Click the canvas to place a circle. Maximum: 6.")
ZStack(alignment: .topLeading) {
Color.gray.opacity(0.1)
ForEach(circles) { circle in
Circle()
.fill(Color.blue)
.frame(width: 24, height: 24)
.position(circle.center)
}
}
.frame(width: 320, height: 200)
.contentShape(Rectangle())
.gesture(
SpatialTapGesture()
.onEnded { value in
addCircle(at: value.location)
}
)
.disabled(isFull)
.border(Color.gray)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Circle canvas")
.accessibilityValue("\(circles.count) of \(limit) circles")
Text(isFull
? "Six circles placed. Clear to start again."
: "\(circles.count) of \(limit) circles placed")
Button("Clear circles") {
circles.removeAll()
}
.disabled(circles.isEmpty)
}
.padding(24)
}
private func addCircle(at point: CGPoint) {
guard !isFull else { return }
// Keep each 24-point circle inside the 320-by-200 canvas.
let center = CGPoint(
x: min(max(point.x, 12), 308),
y: min(max(point.y, 12), 188)
)
circles.append(PlacedCircle(center: center))
}
}
다음은 SurfaceButtonStyle.swift입니다.
import SwiftUI
struct SurfaceButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue.opacity(
configuration.isPressed ? 0.25 : 0.1
))
)
}
}
스타일은 피드백을 그릴 때 configuration.isPressed를 읽으며, 액션 자체는 호출하지 않습니다. Apple은 이 속성을 버튼의 현재 눌림 상태로 정의합니다. 이렇게 하면 렌더링과 액션 실행이 서로 다른 곳에서 처리됩니다. isPressed
원 개수를 여섯 개로 제한한 것은 예시용 애플리케이션 규칙입니다. 두 입력 경로 모두 addCircle(at:)을 사용하며, 이 함수는 추가하기 전에 개수 제한을 확인합니다. 비활성화된 모양은 제한을 알리고, 공유 가드가 변경 규칙을 일관되게 유지합니다. Apple은 disabled(_:)이 클릭과 제스처를 포함해 뷰와의 상호작용을 제어한다고 문서화합니다. disabled(_:)
캔버스에 공간 클릭 제스처를 사용하는 이유는 무엇인가요?
캔버스에는 사용자가 클릭한 지점이 필요합니다. 기본 onTapGesture(count:perform:) 오버로드는 위치 매개변수 없이 액션을 전달합니다. SpatialTapGesture는 위치를 명시적으로 보고하며, 초기화 시 로컬 좌표 공간에서 한 번의 상호작용을 기본값으로 사용합니다. onTapGesture(count:perform:), SpatialTapGesture, init(count:coordinateSpace:)
이 프로그램에서는 원의 위치를 정하는 로컬 좌표를 가진 고정 크기 캔버스에 제스처를 연결합니다. 배치 함수는 원의 반지름에 맞춰 각 중심이 가장자리에서 12포인트 떨어지도록 제한합니다. 따라서 가장자리 근처를 클릭해도 원 전체가 캔버스 안에 배치됩니다. 원끼리 겹칠 수 있으며, 도형이 겹쳐도 개수는 계속 표시됩니다.
버튼과 캔버스는 키보드 동작도 의도적으로 다릅니다. 버튼의 단축키는 해당 버튼을 활성화하는 것과 같은 동작을 수행해 다음 슬롯에 원을 추가합니다. 포인터 위치를 만들어내지는 않습니다. Apple은 키보드 단축키가 컨트롤의 기본 동작을 호출한다고 문서화하며, 동일한 단축키가 여러 컨트롤에 지정되면 먼저 찾은 일치 항목이 실행된다고 설명합니다. 예제 창에서는 이 단축키를 한 번만 지정하세요. keyboardShortcut(_:modifiers:)
캔버스의 접근성 레이블과 개수는 콘텐츠를 설명하지만, 임의 좌표에 배치할 수 있는 키보드 작업 흐름을 제공하지는 않습니다. 버튼은 미리 정해진 슬롯에 원을 추가하는 포인터 외 경로를 제공합니다. 키보드 컨트롤로도 정확한 배치가 가능해야 한다면, 제스처나 설명 레이블이 이를 제공한다고 가정하지 말고 명시적인 위치 편집 인터페이스를 추가하세요.
히트 테스트 영역을 사용자 지정 표면에 맞추려면 어떻게 해야 하나요?
레이블의 크기를 정한 뒤 의도한 상호작용 영역을 정의하세요. 예제에서 버튼 레이블은 280×64 프레임 뒤에 둥근 사각형 contentShape을 둡니다. 캔버스는 320×200 프레임을 설정한 다음 직사각형 콘텐츠 셰이프를 지정합니다. Apple은 contentShape(_:eoFill:)을 히트 테스트에 사용되는 셰이프로 정의합니다. contentShape(_:eoFill:)
이렇게 하면 표시된 표면에 명시적인 상호작용 경계가 생깁니다. 다시 디자인하면서 버튼 레이블 바깥으로 프레임을 옮겼다면 결과를 다시 확인하세요. 예제에서는 크기와 콘텐츠 셰이프가 레이블 안에서 함께 작동합니다. 반응하지 않는 여백을 다른 액션 핸들러를 추가해 해결하려 하지 말고, 그리기와 레이아웃, 상호작용 영역을 의도적으로 맞추세요.
표면이 반응하지 않으면 다음 순서로 확인하세요.
- 기하 구조를 확인합니다. 레이블의 텍스트와 그래픽, 빈 내부 영역을 클릭합니다. 콘텐츠 셰이프가 의도한 경계를 나타내는지 확인합니다.
- 상위 뷰의 비활성화 여부를 확인합니다. 비활성화된 컨테이너는 활성화된 하위 뷰를 재정의하므로, 로컬
.disabled(false)으로 상호작용을 복구하지 못할 수 있습니다. disabled(_:) - 장식용 오버레이를 확인합니다. 순전히 시각적인 뷰라면
.allowsHitTesting(false)으로 히트 테스트에서 제외할 수 있습니다. 이 설정은 상호작용 표면이 아니라 장식 요소에 적용하세요. allowsHitTesting(_:) - 충돌하는 핸들러를 확인합니다. 제스처 우선순위를 바꾸기 전에 어떤 뷰가 액션을 소유하는지 확인합니다.
충돌하는 핸들러와 소유권 실수를 방지하려면 어떻게 해야 하나요?
의도한 각 활성화 경로에 핸들러 하나를 사용하세요. 예제에서 사용자 지정 버튼은 명령을 소유하고, 캔버스는 위치 기반 배치를 담당합니다. 둘 다 배치 제스처가 연결된 상위 뷰 안에 있지 않습니다. 이런 구조에서는 한 번 클릭했을 때 예상되는 효과를 쉽게 확인할 수 있습니다.
같은 변경 함수를 호출하려고 버튼에 두 번째 탭 핸들러를 연결하지 마세요. 마찬가지로 상위 뷰의 제스처를 여러 컨트롤에 추가할 때는 상위 뷰와 하위 뷰의 상호작용이 모두 발생해야 하는지 먼저 결정하세요. Apple의 simultaneousGesture 예제는 상위 및 하위 핸들러가 모두 실행될 수 있음을 보여줍니다. 반면 highPriorityGesture은 연결된 제스처에 더 높은 우선순위를 부여합니다. 이 수정자들은 상호작용 정책을 표현하며, 서로 바꿔 쓸 수 있는 수정 방법이 아닙니다. simultaneousGesture(_:including:), highPriorityGesture(_:including:)
애플리케이션 상태는 해당 상태를 소유하는 뷰에 두세요. 여기서는 ShapeSurfaceView가 예시용 원을 저장하고, 스타일은 전달받은 레이블만 렌더링합니다. Apple은 가장 높은 수준에서 필요한 뷰에 비공개 상태를 두고, 필요에 따라 값이나 바인딩을 하위 뷰에 전달할 것을 권장합니다. State
나중에 AppKit 어댑터가 콜백을 저장한다면 캡처 관계를 별도로 확인하세요. Swift 클로저는 주변 값을 캡처할 수 있습니다. 클로저를 저장하는 클래스가 자기 자신을 캡처하는 클로저를 저장하면 강한 참조 순환이 생길 수 있습니다. 실제 객체 수명에 맞는 캡처 목록을 선택하세요. 버튼을 제스처로 바꿔도 콜백 소유권 문제는 해결되지 않습니다. Closures
AppKit은 언제 적절한 다음 단계인가요?
직접 만든 표면에 문서화된 마우스 전용 설정이 필요하다면 AppKit을 고려하세요. NSClickGestureRecognizer는 필요한 클릭 횟수와 마우스 버튼 마스크를 지원합니다. Apple은 비트 0이 기본 버튼을, 비트 1이 보조 버튼을 나타내며, 드래그 없이 설정된 클릭 횟수만큼 인식되어야 한다고 명시합니다. 또한 설정된 마우스 버튼에 대해 이벤트 지연을 자동으로 활성화합니다. NSClickGestureRecognizer, buttonMask
배포 대상과 소유한 컴포넌트에 따라 통합 경로를 선택하세요.
- macOS 26 이상에서는
NSGestureRecognizerRepresentable이.gesture(...)을 통해 AppKit 인식기를 SwiftUI 계층에 직접 통합합니다. SwiftUI가 액션 대상을 설치합니다. 표현 가능한 타입의 액션 핸들러를 구현하고 좌표 변환에는 컨텍스트 변환기를 사용하세요. Apple은 인식기에 연결된 뷰의 기하 구조가 SwiftUI 뷰와 다를 수 있다고 주의를 줍니다. NSGestureRecognizerRepresentable - SwiftUI에 호스팅된 AppKit 뷰에서는
NSViewRepresentable이 뷰의 생성과 업데이트, 해제를 관리합니다. 콜백에는 코디네이터를 사용하고, 표현된 뷰의 레이아웃은 SwiftUI가 제어하도록 하세요. 이 API는 macOS 10.15부터 사용할 수 있습니다. NSViewRepresentable
어느 어댑터를 추가하든 이벤트 경계를 유지하세요. AppKit 문서에 따르면 인식기는 연결된 뷰와 그 하위 뷰에서 작동하고, 뷰보다 먼저 관련 이벤트를 받으며, 이벤트 전달을 지연할 수 있습니다. 또한 드래그 앤 드롭이나 팝업 메뉴 같은 다른 메커니즘에 이벤트 추적을 넘기는 기능은 지원하지 않는다고 명시합니다. 클릭 인식기를 범용 이벤트 전달기로 취급하지 말고, 해당 상호작용은 지원되는 메커니즘으로 설계하세요. NSGestureRecognizer
완성된 Mac 상호작용은 무엇을 확인해야 하나요?
예제를 더 큰 뷰 계층에 적용하기 전에 Xcode에서 빌드하고 관찰 가능한 동작을 확인하세요.
- 사용자 지정 버튼의 텍스트와 빈 내부 영역을 클릭합니다. 활성화할 때마다 원 하나가 추가되고 개수가 한 번 갱신되어야 합니다.
- 활성 창에서
Command–Shift–A를 누릅니다. 버튼의 슬롯 기반 동작이 실행되어야 합니다. - 가장자리를 포함해 캔버스의 여러 위치를 클릭합니다. 코드에 명시된 여백을 두고 원이 로컬 좌표에 따라 배치되어야 합니다.
- 원이 여섯 개에 도달합니다. 두 배치 경로 모두 추가를 중단하지만
원 지우기는 계속 사용할 수 있어야 합니다. - 캔버스를 지웁니다. 다시 배치할 수 있어야 합니다.
- VoiceOver로 버튼을 확인하고 현재 Mac 설정에서 키보드 탐색을 시험합니다. 스타일을 적용한 뒤에도 버튼의 동작을 이해할 수 있는지 확인합니다.
사용자 지정 Mac 액션 표면은 상호작용의 의미에서 시작하세요. 이름이 지정된 명령에는 Button을 사용하고, 시각적 사용자 지정은 레이블과 스타일에서 처리하며, 위치에 따라 배치하는 동작은 캔버스 제스처에 둡니다. 이 경로들은 하나의 상태 변경 함수로 연결하고, 추가 설정이 구체적인 요건을 충족할 때 AppKit을 도입하세요.
출처
Button — Apple Developer Documentation.
제스처 — Apple Developer Documentation.
ButtonStyle — Apple Developer Documentation.
isPressed — Apple Developer Documentation.
onTapGesture(count:perform:) — Apple Developer Documentation.
SpatialTapGesture — Apple Developer Documentation.
init(count:coordinateSpace:) — Apple Developer Documentation.
contentShape(_:eoFill:) — Apple Developer Documentation.
disabled(_:) — Apple Developer Documentation.
allowsHitTesting(_:) — Apple Developer Documentation.
keyboardShortcut(_:modifiers:) — Apple Developer Documentation.
simultaneousGesture(_:including:) — Apple Developer Documentation.
highPriorityGesture(_:including:) — Apple Developer Documentation.
State — Apple Developer Documentation.
Closures — Swift.org, The Swift Programming Language.
NSClickGestureRecognizer — Apple Developer Documentation.
buttonMask — Apple Developer Documentation.
NSGestureRecognizer — Apple Developer Documentation.
NSGestureRecognizerRepresentable — Apple Developer Documentation.
NSViewRepresentable — Apple Developer Documentation.