겹쳐진 뷰의 투명한 구멍을 통해 SwiftUI 컨트롤을 클릭하려면, 위쪽 뷰에 구멍을 제외하는 명시적 히트 테스트 셰이프를 지정하세요. 이 안내에서는 메모리에만 존재하는 링과 그 아래의 버튼을 사용해 하나의 Mac 앱 안에서 이 상호작용을 구현합니다.

구체적인 작업은 작은 데스크톱 드로잉 인터페이스입니다. 주황색 링을 클릭하면 링이 선택되고, 투명한 가운데를 클릭하면 아래에 보이는 파란색 컨트롤이 활성화됩니다. 두 레이어는 같은 SwiftUI 뷰 계층에 속합니다. 이 예제에서는 드로잉과 히트 테스트가 동일한 지오메트리를 사용하므로 이미지 파일이나 픽셀 샘플링이 필요하지 않습니다.

이벤트 처리를 바꾸기 전에 히트 테스트 규칙 선택하기

Apple은 관련 SwiftUI 제어 수단 두 가지를 문서화했습니다. allowsHitTesting(_:)은 뷰가 히트 테스트에 참여할지 여부를 결정합니다. contentShape(_:eoFill:)는 히트 테스트에 사용할 셰이프를 지정합니다. 클릭을 절대 받아서는 안 되는 장식 요소에는 첫 번째를 사용하고, 구멍이 있는 대화형 오버레이에는 두 번째를 사용하세요. Apple의 히트 테스트 참여 문서와 콘텐츠 셰이프 문서에서는 이 두 가지 역할을 각각 설명합니다.

위쪽 레이어의 요구 사항 구현 방법
장식 요소가 모든 클릭을 무시해야 함 해당 장식 요소에 .allowsHitTesting(false) 적용
원이 외곽선 안쪽에서 반응해야 함 대화형 뷰에 원형 콘텐츠 셰이프 지정
링이 투명한 가운데를 제외한 영역에서 반응해야 함 드로잉과 히트 테스트에 복합 셰이프와 짝수-홀수 채우기 사용
자체 AppKit 하위 뷰 계층이 선택적으로 참여해야 함 hitTest(_:) 경계를 재정의하고 상호작용 영역 밖의 점은 거부

이 API들은 참여 여부와 지오메트리를 지정합니다. 최종 렌더링 픽셀의 알파값을 자동으로 검사한다고 명시하지는 않습니다. 직접 만든 셰이프에는 의도한 상호작용 영역을 명시적으로 설명하는 방법이 지원됩니다. 따라서 투명하게 보이는 것만으로는 상호작용을 완전히 지정할 수 없습니다. 이 구분은 문서화된 불리언 및 셰이프 기반 인터페이스에 따른 것이며, 모든 렌더링 수정자에 적용된다는 보장은 아닙니다.

링이 버튼 위에 겹치는 완전한 SwiftUI 예제 만들기

Xcode에서 SwiftUI를 사용하는 macOS 앱을 만들고, 배포 대상을 macOS 12 이상으로 설정하세요. 생성된 앱 선언과 초기 뷰를 아래 전체 프로그램으로 바꾸고, 타깃에는 @main 선언이 하나만 남도록 하세요. 이 예제에서는 macOS 12부터 사용할 수 있고 히트 테스트를 명시적으로 제어하는 상호작용 종류인 .contentShape(.interaction, …)를 사용합니다. Apple의 상호작용 문서에서 이 동작과 사용 가능 버전을 확인할 수 있습니다.

링에는 동심원인 타원 서브패스 두 개가 있습니다. 드로잉과 상호작용 모두 짝수-홀수 규칙을 사용하므로 안쪽 타원은 구멍이 됩니다. Apple은 Shape.path(in:)을 제공된 사각형 안의 지오메트리로, FillStyle을 짝수-홀수 채우기를 비롯한 렌더링 설정으로 문서화합니다. 셰이프 지오메트리와 FillStyle 문서에서 관련 계약을 확인할 수 있습니다.

import SwiftUI

struct RingShape: Shape {
    func path(in rect: CGRect) -> Path {
        let side = min(rect.width, rect.height)
        let outer = CGRect(
            x: rect.midX - side / 2,
            y: rect.midY - side / 2,
            width: side,
            height: side
        )
        let inner = outer.insetBy(
            dx: side * 0.30,
            dy: side * 0.30
        )

        var path = Path()
        path.addEllipse(in: outer)
        path.addEllipse(in: inner)
        return path
    }
}

struct RoutingDemo: View {
    @State private var lowerClicks = 0
    @State private var ringClicks = 0

    var body: some View {
        VStack(spacing: 18) {
            Text("Click the center or the orange ring")

            ZStack {
                Button {
                    lowerClicks += 1
                } label: {
                    Rectangle()
                        .fill(Color.blue)
                        .frame(width: 240, height: 240)
                        .overlay {
                            Text("Below")
                                .foregroundColor(.white)
                        }
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)

                RingShape()
                    .fill(Color.orange, style: FillStyle(eoFill: true))
                    .frame(width: 240, height: 240)
                    .contentShape(.interaction, RingShape(), eoFill: true)
                    .onTapGesture {
                        ringClicks += 1
                    }

                Rectangle()
                    .stroke(Color.gray, lineWidth: 1)
                    .frame(width: 240, height: 240)
                    .allowsHitTesting(false)
            }
            .frame(width: 240, height: 240)

            Text("Below: \(lowerClicks) | Ring: \(ringClicks)")
        }
        .padding(24)
    }
}

@main
struct TransparentRoutingApp: App {
    var body: some Scene {
        WindowGroup {
            RoutingDemo()
        }
    }
}

아래쪽 버튼은 자체 동작을 계속 담당합니다. 링의 제스처는 별도의 카운터를 증가시킵니다. 후행 클로저는 두 동작을 직접 표현하며, Swift 언어 가이드에서는 클로저가 주변 값을 캡처할 수 있는 기능이라고 설명합니다. The Swift Programming Language: 클로저에서 구문과 캡처 동작을 설명합니다.

회색 외곽선은 장식 요소이므로 히트 테스트를 개별적으로 비활성화합니다. 이 수정자는 외곽선에 유지하세요. 대화형 레이어를 담은 컨테이너에 적용하면 컨테이너에 잘못된 참여 정책이 설정됩니다. 링 자체는 주황색 띠에서 상호작용할 수 있어야 합니다.

같은 채우기 규칙을 두 번 적용해야 하는 이유

드로잉 호출과 콘텐츠 셰이프 수정자 모두 eoFill: true를 받습니다. 드로잉에만 이 값을 설정하면 보이는 구멍은 지정되지만, 히트 테스트에도 같은 규칙이 적용된다고 명시적으로 지정한 것은 아닙니다. Apple은 콘텐츠 셰이프의 매개변수를 렌더링과 별도로 문서화하므로 이 예제에는 두 선언이 모두 필요합니다. 콘텐츠 셰이프 수정자는 상호작용 셰이프와 그 감기 규칙을 정의합니다.

크기와 변환도 일치시켜야 합니다. 여기서는 링 드로잉과 콘텐츠 셰이프가 모두 240포인트 정사각형을 사용합니다. 나중에 링 뷰 전체를 이동하거나 크기 조정할 경우 두 작업을 함께 적용하세요. 더 큰 컨테이너 안의 드로잉 경로만 변경한다면 상호작용 지오메트리도 같은 의도된 영역을 설명하도록 업데이트하세요.

세 지점을 클릭해 라우팅 확인하기

앱 창을 활성화한 다음 셰이프 경계에서 충분히 떨어진 곳을 클릭하세요. 다음 지점은 240포인트 정사각형 안의 예시 로컬 좌표이며, 왼쪽 위 모서리를 기준으로 측정합니다. 안티앨리어싱된 가장자리 픽셀에 의존하지 않고 가운데 구멍, 주황색 띠, 투명한 모서리를 구분합니다.

지점 위쪽 레이어에서 보이는 지오메트리 예상되는 카운터 변화
(120, 120) 가운데 구멍 Below가 한 번 증가
(210, 120) 주황색 띠 Ring이 한 번 증가
(10, 10) 링 바깥 원 외부 Below가 한 번 증가

바깥쪽 반지름은 120포인트입니다. 안쪽은 각 변에서 72포인트씩 안으로 들어가므로 안쪽 반지름은 48포인트입니다. 세 테스트 지점은 중심에서 각각 0, 90, 약 155.6포인트 떨어져 있습니다. 이 결정적 지오메트리 계산은 Python으로 확인했습니다. Swift 툴체인을 사용할 수 없었기 때문에 이 환경에서는 SwiftUI 앱을 살펴보기만 했고 컴파일하거나 실행하지는 못했습니다.

이 테스트 순서는 진단에도 유용합니다. 주황색 띠가 반응하지 않으면 링의 제스처와 참여 수정자를 확인하세요. 가운데를 클릭했을 때 링이 반응한다면 콘텐츠 셰이프와 짝수-홀수 매개변수를 확인하세요. 구멍에서 어느 레이어도 반응하지 않으면 추가 오버레이를 일시적으로 제거한 다음 하나씩 다시 추가해 라우팅이 바뀌는 레이어를 찾으세요.

직접 코드에서 지오메트리를 확인할 때는 Path.contains(_:eoFill:)에도 같은 짝수-홀수 선택지를 사용할 수 있습니다. 직접 만든 경로의 좌표계로 표현한 점을 전달하세요. 이렇게 하면 스크린샷을 상호작용 모델로 취급하지 않고도 지오메트리 테스트에서 동일한 내부 또는 외부 여부를 확인할 수 있습니다. Apple의 경로 포함 여부 문서에서 이 연산을 설명합니다.

직접 관리하는 경계에서만 AppKit 히트 테스트 사용하기

오버레이가 이미 AppKit으로 구현되어 있다면 NSView.hitTest(_:)이 문서화된 사용자 지정 지점입니다. 이 메서드는 점에 따라 선택된 하위 뷰를 반환하며, 주로 마우스 다운을 받을 대상을 선택하는 데 사용됩니다. 입력 점은 리시버의 슈퍼뷰 좌표계에 있으므로 로컬 지오메트리와 비교하기 전에 변환하세요. Apple의 NSView 히트 테스트 문서에서 이러한 세부 사항을 명시합니다.

직접 관리하는 사용자 지정 뷰에서는 재정의한 메서드가 상호작용 영역 밖의 점을 거부하고, 허용된 점은 super.hitTest(_:)에 넘길 수 있습니다. 다음 코드는 두 번째 전체 앱이 아니라 기존 사용자 지정 NSView 하위 클래스에 넣는 메서드 조각입니다. 위에서 정의한 RingShape를 사용하며 import SwiftUI와 함께 import AppKit이 필요합니다.

override func hitTest(_ point: NSPoint) -> NSView? {
    guard let parent = superview else {
        return super.hitTest(point)
    }

    let localPoint = convert(point, from: parent)
    let region = RingShape().path(in: bounds)

    guard region.contains(localPoint, eoFill: true) else {
        return nil
    }

    return super.hitTest(point)
}

사용자 지정 뷰의 보이는 콘텐츠와 하위 뷰 계층이 실제로 해당 링 지오메트리를 따르는 경우에만 이 코드를 사용하세요. nil를 반환하면 해당 점에 대해 그 하위 뷰 계층을 거부합니다. 이는 목적지나 명령을 지정하거나 SwiftUI에 특정 형제 뷰를 활성화하라고 지시하는 것이 아닙니다. 의도한 아래쪽 레이어가 클릭을 받을 수 있는지는 주변 계층 구조에 따라 달라집니다. 해당 점을 계속 허용하는 상위 뷰가 방해 요소로 남을 수 있습니다.

직접 관리하는 AppKit 뷰를 SwiftUI에 임베드할 때는 NSViewRepresentable을 사용해 뷰를 만들고 설정을 업데이트하세요. representable의 업데이트 메서드를 통해 항목 상태와 상호작용 지오메트리를 동기화하고, 델리게이트 또는 타깃-액션 메시지를 연결할 때는 코디네이터를 사용하세요. Apple은 표현된 루트 뷰의 frame와 bounds를 SwiftUI의 레이아웃 제어에 맡기라고도 안내합니다. Apple의 NSViewRepresentable 문서에서 이러한 생명 주기와 소유권 경계를 설명합니다.

원래 이벤트와 제스처 소유자를 유지하기

라우팅 결정은 히트 테스트 중에 내리세요. 위쪽 레이어의 클릭을 받아 아래쪽 컨트롤의 동작을 직접 호출하지 마세요. 그렇게 하면 아래쪽 컨트롤의 정상적인 상호작용 대신 앱 명령을 실행하게 됩니다. 마찬가지로 이 예제에는 이벤트 재전송, SwiftUI 구현 뷰 순회, 아래쪽 레이어의 제스처 처리기 교체가 필요하지 않습니다. 화면에 보이는 각 대화형 레이어가 자체 동작을 담당합니다.

여기서 다루는 범위는 앱 뷰 계층 안에서 일반적인 기본 버튼 활성화입니다. 가운데 클릭이 성공한다고 해서 스크롤, 드래그, 호버, 컨텍스트 메뉴 동작까지 보장되는 것은 아닙니다. 인터페이스에 이런 기능을 추가한다면 각각 따로 테스트하세요. 특히 Apple은 상호작용 콘텐츠 셰이프를 드래그 미리보기 및 컨텍스트 메뉴 미리보기 셰이프와 구분하며, 이 셰이프들은 히트 테스트를 바꾸지 않습니다. Apple의 콘텐츠 셰이프 문서에서 이러한 별도 종류를 설명합니다.

이 데스크톱 작업에서는 링의 경로를 보이는 띠와 클릭 가능한 영역의 공통 정의로 유지하세요. 그러면 투명한 가운데의 의미가 정확해집니다. 위쪽 레이어는 해당 점을 거부하고 아래쪽 SwiftUI 컨트롤은 정상적인 상호작용 시스템을 통해 반응합니다.

출처