Mac에서 SwiftUI 행의 빈 공간을 클릭 가능하게 하려면 행의 콘텐츠를 사용 가능한 너비만큼 확장하고 직사각형 콘텐츠 셰이프를 적용하세요. 행이 동작을 수행한다면 두 수정자를 모두 Button 레이블 안에 넣으세요.
이 튜토리얼에서는 모양과 텍스트로 이루어진 행이 있는 간단한 macOS 앱을 만듭니다. 모양, 레이블 또는 그 옆의 빈 공간을 클릭하면 동일한 행 동작이 실행됩니다. 행 바깥의 공간은 해당 동작의 대상에 포함되지 않습니다.
텍스트는 반응하는데 빈 공간은 반응하지 않는 이유는 무엇인가요?
레이아웃과 히트 테스트는 서로 다른 질문에 답합니다. 프레임은 뷰가 제안된 공간을 어떻게 차지하는지 결정하고, 콘텐츠 셰이프는 히트 테스트에 사용되는 영역을 정의합니다. Apple은 유연한 프레임 수정자와 콘텐츠 셰이프 수정자 문서에서 이를 각각 설명합니다.
예를 들어 컨테이너가 창을 채워 행이 넓어 보이더라도 상호작용은 짧은 텍스트 뷰에만 연결되어 있을 수 있습니다. 마찬가지로 빈 레이아웃 공간을 추가하는 것만으로는 행 전체가 히트 테스트에 포함되어야 한다고 명시할 수 없습니다. 실용적인 해결 방법은 먼저 의도한 행의 경계를 설정한 다음 그 영역에 상호작용 셰이프를 지정하는 것입니다.
.frame(maxWidth: .infinity, alignment: .leading)을 사용하면 부모가 추가 너비를 제안할 때 레이블을 확장할 수 있습니다. 그런 다음 확장된 레이블에 .contentShape(.interaction, Rectangle())를 적용하세요. Apple은 interaction 콘텐츠 셰이프 종류를 히트 테스트와 접근성용으로 명시적으로 정의합니다.
이렇게 해도 행이 무한히 넓어지는 것은 아닙니다. 프레임은 여전히 부모의 레이아웃 제안에 따라 배치됩니다. 부모가 좁으면 행도 좁아지며, 콘텐츠 셰이프가 부족한 레이아웃 공간을 만들어 주지는 않습니다. 이 조합은 충분히 넓은 레이블과 명시적인 히트 테스트 영역이라는 두 가지 요구 사항을 충족합니다.
완전한 행 버튼 예제 만들기
Xcode에서 SwiftUI와 Swift를 사용하는 macOS 앱 프로젝트를 만드세요. 명시적인 .interaction 콘텐츠 셰이프 오버로드는 Apple 문서에 macOS 12부터 사용할 수 있다고 나와 있으므로 배포 대상을 macOS 12 이상으로 설정하세요. 생성된 앱 선언과 콘텐츠 뷰를 아래 단일 파일 프로그램으로 바꾸고, @main 앱 선언은 하나만 유지하세요. 사용 가능 여부는 Apple의 콘텐츠 셰이프 오버로드 문서를 참조하세요.
각 행이 동작을 시작하므로 이 예제에서는 Button을 사용합니다. Apple은 동작을 수행하는 인터페이스 요소에 버튼을 권장하며, 버튼 레이블로 임의의 뷰를 명시적으로 지원합니다. Apple의 탭 제스처 문서에서도 제스처 기반 컨트롤이 버튼과 기능적으로 동등하다면 버튼을 사용자화할 것을 권장합니다.
import SwiftUI
@main
struct ClickableRowsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct SampleItem: Identifiable {
let id: Int
let title: String
let color: Color
}
struct ContentView: View {
private let items = [
SampleItem(id: 1, title: "Blue square", color: .blue),
SampleItem(id: 2, title: "Orange square", color: .orange),
SampleItem(id: 3, title: "Purple square", color: .purple)
]
@State private var lastActivated = "None"
@State private var activationCount = 0
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Choose a shape")
.font(.headline)
ScrollView {
VStack(spacing: 8) {
ForEach(items) { item in
Button {
lastActivated = item.title
activationCount += 1
} label: {
HStack(spacing: 12) {
Rectangle()
.fill(item.color)
.frame(width: 24, height: 24)
Text(item.title)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity,
alignment: .leading)
.contentShape(.interaction, Rectangle())
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.12))
}
}
}
Text("Last activated: \(lastActivated)")
Text("Activations: \(activationCount)")
}
.padding(16)
.frame(minWidth: 360, minHeight: 280)
}
}
모든 항목과 상호작용 상태는 메모리에만 유지됩니다. 각 항목은 안정적인 정체성을 나타내는 Swift의 Identifiable 프로토콜에 맞춰 고정된 정수 식별자를 가집니다. 부모 뷰가 활성화 상태를 소유하며, 필요한 뷰에서 로컬 State를 비공개로 유지하라는 Apple의 지침을 따릅니다.
앱을 실행하고 제목 오른쪽의 빈 영역을 클릭하세요. 활성화 횟수가 한 번 증가하고 상태에 해당 행의 이름이 표시되어야 합니다. 회색 배경은 이 확인 중 의도한 행 영역을 보여 주며, 명시적인 콘텐츠 셰이프가 상호작용 영역을 정의합니다.
이 프로그램은 가져온 API 문서와 대조해 검토했지만, Swift 툴체인을 사용할 수 없어 여기서 컴파일하거나 실행하지 않았습니다. 통합하기 전에 대상 macOS 환경에서 포인터와 키보드 동작을 확인하세요.
프레임, 패딩, 콘텐츠 셰이프는 어디에 배치해야 하나요?
이 예제에서는 순서를 의도적으로 정했습니다. 레이블 콘텐츠를 구성하고, 내부 패딩을 추가한 뒤, 만들어진 레이블을 확장하고, 그 영역에 상호작용 셰이프를 정의합니다. 이 모든 작업은 버튼의 레이블 클로저 안에서 이루어집니다. Apple은 수정자 순서가 중요하다고 설명합니다.
패딩을 셰이프가 적용된 레이블 안에 넣으면 패딩된 영역도 의도한 대상에 포함됩니다. 대신 버튼 바깥에 패딩을 넣으면 주변 레이아웃 공간이 생깁니다. 그 공간이 버튼의 상호작용 레이블에 포함된다고 가정하지 마세요. 마찬가지로 바깥 컨테이너만 확장하면 버튼 안의 콘텐츠를 확장하는 것과 같은 레이블 지오메트리가 만들어지지 않습니다.
.buttonStyle(.plain) 수정자는 예제를 시각적으로 단순하게 유지합니다. Apple은 일반 버튼 스타일을 대기 상태에서는 꾸밈이 없는 스타일로 설명하며, 눌림, 포커스 또는 활성화 상태에서는 효과가 나타날 수 있다고 합니다. 이는 스타일 선택일 뿐, 레이블의 너비와 콘텐츠 셰이프를 정의하는 대안이 아닙니다.
macOS 12보다 이전 버전을 배포 대상으로 삼는다면 .contentShape(.interaction, Rectangle())를 .contentShape(Rectangle())로 바꾸세요. Apple은 이전 오버로드가 히트 테스트 셰이프를 정의한다고 설명하며 macOS 10.15부터 사용할 수 있다고 명시합니다. 이 대체는 콘텐츠 셰이프 API 요구 사항만 바꾸므로 앱의 다른 API는 별도로 확인하세요.
행에는 버튼, 목록 선택, 제스처 중 무엇을 사용해야 하나요?
행을 클릭했을 때 어떤 의미인지에 따라 상호작용 방식을 선택하세요. 빈 공간에서도 반응하도록 만들 때는 그 의미와 행 안의 컨트롤 소유 관계를 유지해야 합니다.
| 사용자에게 보이는 동작 | 적절한 시작점 | 유지해야 할 경계 |
|---|---|---|
| 행 전체가 하나의 동작을 수행함 | 확장하고 셰이프를 지정한 레이블이 있는 Button |
행의 동작 소유자는 하나로 유지 |
| 행이 현재 선택 항목을 나타냄 | 선택 바인딩이 있는 List |
선택 동작은 목록이 담당 |
| 사용자 지정 뷰에 별도의 클릭 제스처가 필요함 | onTapGesture이 적용된 셰이프 뷰 |
의도한 영역에 제스처 연결 |
| 행에 서로 독립적인 컨트롤이 여러 개 있음 | 별도의 형제 컨트롤 | 각 컨트롤에 자체 대상 지정 |
목록 선택에는 행 전체 클릭 핸들러를 추가하기 전에 컨테이너의 선택 모델을 사용하세요. Apple의 List 문서는 단일 선택에는 선택적 항목 식별자 바인딩을, 다중 선택에는 식별자 집합 바인딩을 설명합니다. 동작을 수행하는 버튼과 선택 상태가 되는 목록 행은 서로 다른 상호작용 요구 사항입니다.
사용자 지정 뷰에서 제스처 인식이 특별히 필요한 경우에는 제스처를 사용해도 됩니다. onTapGesture를 연결하기 전에 패딩과 프레임을 적용한 다음 콘텐츠 셰이프를 지정하는 동일한 지오메트리 순서를 따르세요. Apple은 해당 제스처의 count 매개변수를 필요한 탭 또는 클릭 횟수로 설명합니다. 콘텐츠 셰이프는 대상 영역을 제공하지만, 그 자체로 동작을 제공하지는 않습니다. onTapGesture(count:perform:)을 참조하세요.
행에 별도의 버튼이나 토글도 있다면 기본 버튼의 레이블 바깥에 두세요. 수평 스택 안에 확장되는 기본 버튼과 별도의 끝쪽 컨트롤을 배치하는 구조가 유용합니다. 기본 레이블의 빈 공간은 해당 동작에 속하고, 끝쪽 컨트롤은 자체 영역을 소유합니다. 상호작용 컨트롤을 행 전체를 감싸는 버튼 안에 중첩하지 마세요.
빈 공간의 대상 지정 문제를 해결하려고 제스처 우선순위 수정자를 추가하지 마세요. Apple은 highPriorityGesture가 우선순위를 바꾸고 simultaneousGesture가 제스처를 함께 처리하도록 한다고 설명합니다. 이러한 동작은 제스처 간 관계를 바꾸며 하위 뷰의 상호작용에 영향을 줄 수 있습니다. 빠진 행 지오메트리를 설정해 주지는 않습니다.
행이 여전히 클릭을 놓치는 경우 진단하기
행의 가장 작은 형태에서 다음 순서대로 확인하세요. 크기 문제, 대상 셰이프 문제, 뷰 겹침 문제를 구분할 수 있습니다.
- 실제 레이블 경계를 확인합니다. 레이블의 프레임 수정자 바로 뒤에
.border(Color.orange)를 임시로 추가하세요. 테두리가 텍스트 옆에서 끝난다면 부모가 제안하는 너비와 고정 너비 제약을 확인하세요. - 수정자의 위치를 확인합니다. 콘텐츠 셰이프가 확장된 레이블 전체를 덮고, 클릭 가능하게 만들려는 패딩도 포함하는지 확인하세요.
- 상호작용 소유자를 확인합니다. 버튼 행에는 버튼 동작을 사용하세요. 문제를 분리하는 동안 중복된 행 탭 제스처를 제거하세요.
- 겹치는 장식을 확인합니다. 행 위에 놓인 오버레이와 형제 뷰를 살펴보세요. 입력을 전혀 받아서는 안 되는 장식에는 해당 장식에만
.allowsHitTesting(false)를 적용해 보세요. - 다른 컨트롤을 하나씩 복원합니다. 각 컨트롤을 복원한 뒤 해당 영역을 테스트하고, 나머지 빈 공간도 확인하세요.
Apple은 allowsHitTesting(_:)가 뷰의 히트 테스트 참여 여부를 제어한다고 설명합니다. 이를 행 전체에 적용하면 행이 참여하지 않게 되므로, 그런 동작이 의도된 경우에는 해당 장식 뷰만 대상으로 지정하세요.
서로 다른 몇 군데를 클릭해 결과를 확인하세요. 아래 표는 제공된 프로그램에서 기대하는 동작을 설명하며, 측정된 테스트 결과를 나타내지는 않습니다.
| 클릭 위치 | 예상 결과 |
|---|---|
| 색상 사각형 또는 행 제목 | 해당 행이 한 번 활성화됨 |
| 회색 행 안에서 오른쪽에 있는 빈 공간 | 해당 행이 한 번 활성화됨 |
| 제목의 위나 아래에 있는 내부 패딩 | 해당 행이 한 번 활성화됨 |
| 행 사이의 간격 | 행이 활성화되지 않음 |
| 바깥쪽 패딩 또는 행 아래의 빈 공간 | 행이 활성화되지 않음 |
창 크기를 조절한 뒤 빈 공간 확인을 반복하세요. Mac의 키보드 탐색 설정으로 키보드 포커스와 활성화 동작도 확인하세요. 이러한 점검을 통해 포인터에 반응하는지만 보는 것을 넘어 최종 컴포넌트가 의도한 컨트롤처럼 동작하는지 확인할 수 있습니다.
AppKit 히트 테스트는 언제 중요할까요?
일반적인 SwiftUI 행에서는 먼저 SwiftUI의 레이아웃, 콘텐츠 셰이프, 컨트롤 API를 사용하세요. 앱에 사용자 지정 네이티브 뷰나 이벤트 대상을 바꾸는 임베디드 뷰가 있을 때 AppKit이 관련됩니다.
Apple은 NSView.hitTest(_:)가 특정 지점을 포함하는 가장 먼 하위 뷰를 찾는다고 설명합니다. 창은 주로 마우스다운 이벤트를 받을 뷰를 결정하는 데 이를 사용합니다. 입력 지점은 뷰의 상위 뷰 좌표계로 표현되며, Apple은 이를 직접 호출할 일은 거의 없다고 말합니다.
사용자 지정 AppKit 뷰를 관리한다면 해당 경계에서 뷰의 경계와 히트 테스트 재정의를 살펴보세요. SwiftUI 콘텐츠 셰이프는 별도의 네이티브 뷰가 소유한 이벤트를 우회하라는 지시가 아닙니다. 클릭을 받는 계층 구조를 담당하는 뷰 안에서 수정하세요.
이 튜토리얼의 행에는 지원되는 방법이 간단합니다. 버튼 레이블이 의도한 행 너비를 차지하게 하고, 직사각형 상호작용 셰이프를 정의하며, 다른 컨트롤은 각자의 영역에 둡니다. 이렇게 하면 창의 나머지 상호작용을 그대로 유지하면서 유용한 빈 공간을 클릭 가능하게 만들 수 있습니다.
출처
- contentShape(_:eoFill:) — Apple
- contentShape(::eoFill:) — Apple
- interaction — Apple
- frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) — Apple
- Button — Apple
- plain — Apple
- padding(::) — Apple
- Identifiable — Apple
- State — Apple
- List — Apple
- onTapGesture(count:perform:) — Apple
- highPriorityGesture(_:including:) — Apple
- simultaneousGesture(_:including:) — Apple
- allowsHitTesting(_:) — Apple
- hitTest(_:) — Apple