重なり合うビューの透明な穴を通してSwiftUIコントロールをクリックできるようにするには、穴を除外する明示的なヒットテスト形状を上側のビューに設定します。この手順では、メモリ内のリングとその下のボタンを使って、1つのMacアプリ内にその操作を構築します。

具体的な課題は、小さなデスクトップ描画インターフェースです。オレンジ色のリングをクリックするとリングが選択され、透明な中央部分をクリックすると、その下に見えている青いコントロールが反応します。2つのレイヤーは同じSwiftUIビュー階層に属します。この例では描画とヒットテストに共通のジオメトリを使うため、画像ファイルもピクセルのサンプリングも必要ありません。

イベント処理を変更する前にヒットテストのルールを選ぶ

Appleは、SwiftUIに関する2つの関連コントロールを文書化しています。allowsHitTesting(_:)は、ビューがヒットテストに参加するかどうかを決めます。contentShape(_:eoFill:)は、ヒットテストに使う形状を指定します。クリックを一切受け取らない装飾には前者を、穴のあるインタラクティブなオーバーレイには後者を使います。Appleのヒットテスト参加に関するドキュメントとコンテンツ形状に関するドキュメントでは、これらの役割が別々に説明されています。

上側レイヤーの要件 実装方法
装飾がどのクリックも受け取らないようにする その装飾に.allowsHitTesting(false)を適用する
円の輪郭の内側で反応させる インタラクティブなビューに円形のコンテンツ形状を設定する
透明な中央部分のあるリングを反応させる 描画とヒットテストの両方に複合形状と偶奇規則を使う
所有するAppKitサブツリーで選択的に参加させる hitTest(_:)の境界をオーバーライドし、操作領域の外にあるポイントを拒否する

これらのAPIが定めるのは参加の有無とジオメトリであり、最終的に描画されたピクセルのアルファ値を自動で検査することではありません。合成した形状では、意図する操作領域を明示的に記述するのがサポートされた方法です。そのため、見た目が透明というだけでは、操作仕様として不十分です。この区別は、文書化されたブール値ベースと形状ベースのインターフェースに基づくものであり、すべての描画モディファイアについての保証ではありません。

リングをボタンに重ねたSwiftUIの例を完成させる

XcodeでSwiftUIを使ってmacOSアプリを作成し、デプロイメントターゲットをmacOS 12以降に設定します。生成されたアプリ宣言と初期ビューを、以下の完全なプログラムに置き換え、ターゲット内の@main宣言は1つだけにします。この例では.contentShape(.interaction, …)を使用します。その操作種別はmacOS 12から利用でき、ヒットテストを明示的に制御します。Appleの操作に関するドキュメントでは、その動作と利用可能なOSバージョンが説明されています。

リングには、同心円状の2つの楕円サブパスがあります。描画と操作の両方で偶奇規則を使い、内側の楕円を穴にします。AppleはShape.path(in:)を、指定された矩形内のジオメトリとして文書化しています。また、FillStyleは偶奇規則による塗りつぶしを含む描画設定です。Shapeのジオメトリと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()
        }
    }
}

下側のボタンは引き続き自身のアクションを担当します。リングのジェスチャは別のカウンターを増やします。末尾のクロージャは2つのアクションを直接表現しており、Swiftの言語ガイドでは、周囲の値をキャプチャできる機能としてクロージャが説明されています。The Swift Programming Language: Closuresでは、構文とキャプチャの動作を解説しています。

灰色の輪郭は装飾なので、そのヒットテストだけを個別に無効にします。このモディファイアは輪郭に適用したままにしてください。インタラクティブなレイヤーを含むコンテナに適用すると、コンテナに誤った参加ポリシーが設定されます。リング自体は、オレンジ色の帯で操作できる状態にしておく必要があります。

塗りつぶし規則が2回必要な理由

描画呼び出しとコンテンツ形状モディファイアのそれぞれに、eoFill: trueを指定します。描画だけに設定した場合、見た目の穴は定義できますが、ヒットテストにも同じ規則を使うことは明示されません。Appleはコンテンツ形状のパラメーターを描画とは別に文書化しているため、この例では両方の宣言が必要です。コンテンツ形状モディファイアは、操作形状とその巻き方向の規則を定義します。

寸法と変換も揃えてください。この例では、リングの描画とコンテンツ形状の両方で240ポイント四方を使います。後からリングビュー全体を移動または拡大縮小する場合は、これらの操作を一緒に行ってください。大きなコンテナ内の描画パスだけを変更する場合は、意図する領域が同じになるよう、操作用のジオメトリも更新します。

3つの具体的なクリックでルーティングを確認する

アプリのウィンドウをアクティブにし、形状の境界から十分離れた場所をクリックします。以下は240ポイント四方の内側にあるローカル座標の例で、左上隅から測定します。アンチエイリアスがかかった端のピクセルに頼らず、中央の穴、オレンジ色の帯、透明な角を区別できます。

ポイント 上側レイヤーに見えている形状 期待されるカウンターの変化
(120, 120) 中央の穴 Belowが1増える
(210, 120) オレンジ色の帯 Ringが1増える
(10, 10) リングの外側の円の外 Belowが1増える

外側の半径は120ポイントです。内側のインセットは各辺72ポイントなので、内側の半径は48ポイントです。3つのテストポイントは、それぞれ中心から0、90、約155.6ポイントの位置です。この決定的なジオメトリ計算はPythonで確認しました。Swiftツールチェーンがこの環境になかったため、SwiftUIアプリは調査しましたが、コンパイルや実行はできませんでした。

このテストは、問題を切り分けるための診断手順としても役立ちます。オレンジ色の帯が反応しない場合は、リングのジェスチャと参加モディファイアを確認してください。中央でリングが反応する場合は、コンテンツ形状と偶奇パラメーターを確認します。穴の部分でどちらのレイヤーも反応しない場合は、追加のオーバーレイを一時的に取り除き、1つずつ戻して、どのレイヤーがルーティングを変えるかを特定します。

自分のコードでジオメトリを直接確認する場合は、Path.contains(_:eoFill:)でも同じ偶奇規則を指定できます。構築したパスの座標系で表したポイントを渡します。これにより、スクリーンショットを操作モデルとして扱わずに、ジオメトリテストで同じ包含判定を行えます。Appleのパス包含判定に関するドキュメントで、この処理が説明されています。

自分が所有する境界でのみAppKitのヒットテストを使う

オーバーレイがすでにAppKitで実装されている場合、NSView.hitTest(_:)が文書化されたカスタマイズポイントです。このメソッドは、あるポイントに対して選択された子孫を返し、主にマウスダウンを受け取るビューの選択に使われます。入力ポイントはレシーバーのスーパービュー座標で表されるため、ローカルなジオメトリと比較する前に座標変換してください。AppleのNSViewヒットテストに関するドキュメントでは、これらの詳細が規定されています。

自分が所有するカスタムビューなら、オーバーライドで操作領域の外にあるポイントを拒否し、受け入れるポイントはsuper.hitTest(_:)に渡せます。次のコードは既存のカスタムNSViewサブクラス用のメソッド断片であり、2つ目の完全なアプリではありません。上記で定義した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コントロールは通常の操作システムを通じて反応する役割を保ちます。

ソース