Pour cliquer sur un contrôle SwiftUI à travers un trou transparent dans une vue superposée, donnez à la vue supérieure une forme explicite de test de hit-test qui exclut le trou. Ce guide construit cette interaction dans une seule app Mac, à l’aide d’un anneau en mémoire et d’un bouton placé dessous.

La tâche concrète consiste à créer une petite interface de dessin pour ordinateur : cliquer sur l’anneau orange le sélectionne ; cliquer sur son centre transparent active le contrôle bleu visible en dessous. Les deux couches appartiennent à la même hiérarchie de vues SwiftUI. L’exemple utilise une géométrie commune au dessin et au test de hit-test ; il ne nécessite donc ni fichiers image ni échantillonnage des pixels.

Choisir la règle de hit-test avant de modifier la gestion des événements

Apple documente deux contrôles SwiftUI pertinents. allowsHitTesting(_:) détermine si une vue participe ou non au hit-test. contentShape(_:eoFill:) spécifie la forme utilisée pour le hit-test. Utilisez le premier pour les éléments décoratifs qui ne doivent jamais recevoir de clics ; utilisez le second pour une superposition interactive comportant des trous. La documentation d’Apple sur la participation au hit-test et celle sur les formes de contenu décrivent ces responsabilités distinctes.

Exigence concernant la couche supérieure Choix d’implémentation
L’élément décoratif doit ignorer tous les clics Appliquer .allowsHitTesting(false) à cet élément décoratif
Un cercle doit réagir à l’intérieur de son contour Donner une forme de contenu circulaire à sa vue interactive
Un anneau doit réagir autour d’un centre transparent Utiliser une forme composée et le remplissage pair-impair pour le dessin et le hit-test
Une sous-arborescence AppKit dont vous êtes responsable doit participer de façon sélective Redéfinir sa méthode hitTest(_:) et rejeter les points situés hors de sa zone d’interaction

Ces API décrivent la participation et la géométrie ; elles ne spécifient pas de test automatique de l’alpha du pixel final rendu. Pour les formes synthétiques, la méthode prise en charge consiste à décrire explicitement la zone d’interaction voulue. Une apparence transparente ne suffit donc pas à définir entièrement l’interaction. Cette distinction découle des interfaces documentées, fondées sur un booléen et une forme, et ne constitue pas une garantie concernant chaque modificateur de rendu.

Construire un exemple SwiftUI complet avec un anneau au-dessus d’un bouton

Créez une app macOS dans Xcode avec SwiftUI et définissez macOS 12 ou une version ultérieure comme cible de déploiement. Remplacez la déclaration d’app générée et la vue initiale par le programme complet ci-dessous, en ne conservant qu’une seule déclaration @main dans la cible. L’exemple utilise .contentShape(.interaction, …), dont le type d’interaction est disponible à partir de macOS 12 et contrôle explicitement le hit-test. La documentation d’Apple sur l’interaction précise ce comportement et sa disponibilité.

L’anneau contient deux sous-chemins elliptiques concentriques. Le dessin et l’interaction utilisent tous deux la règle pair-impair, ce qui fait de l’ellipse intérieure un trou. Apple documente Shape.path(in:) comme la géométrie dans le rectangle fourni, et FillStyle comme la configuration du rendu, y compris le remplissage pair-impair. Les formes géométriques et FillStyle définissent les contrats sous-jacents.

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()
        }
    }
}

Le bouton du dessous reste responsable de sa propre action. Le geste de l’anneau incrémente un compteur différent. Ces fermetures de fin d’appel expriment directement les deux actions ; le guide du langage Swift décrit les fermetures comme des fonctionnalités capables de capturer des valeurs environnantes. The Swift Programming Language : Closures explique la syntaxe et le comportement de capture.

Le contour gris est décoratif ; son hit-test est donc désactivé individuellement. Laissez ce modificateur sur le contour : l’appliquer à un conteneur qui englobe les couches interactives donnerait au conteneur une politique de participation inadaptée. L’anneau lui-même doit pouvoir recevoir des interactions sur sa bande orange.

Pourquoi la même règle de remplissage est nécessaire deux fois

L’appel de dessin et le modificateur de forme de contenu reçoivent chacun eoFill: true. Le définir uniquement pour le dessin décrirait le trou visible sans indiquer explicitement au hit-test d’utiliser la même règle. Apple documente le paramètre de forme de contenu séparément du rendu ; les deux déclarations sont donc nécessaires dans cet exemple. Le modificateur de forme de contenu définit la forme d’interaction et sa règle d’enroulement.

Veillez également à aligner les dimensions et les transformations. Ici, le dessin de l’anneau et sa forme de contenu utilisent le même carré de 240 points. Si vous déplacez ou redimensionnez ensuite toute la vue de l’anneau, appliquez ces opérations ensemble. Si vous modifiez plutôt un chemin de dessin à l’intérieur d’un conteneur plus grand, mettez à jour la géométrie d’interaction pour qu’elle décrive la même zone voulue.

Vérifier le routage avec trois clics précis

Activez la fenêtre de l’app, puis cliquez loin des contours. Les points suivants sont des exemples de coordonnées locales à l’intérieur du carré de 240 points, mesurées depuis son coin supérieur gauche. Ils distinguent le trou central, la bande orange et le coin transparent, sans dépendre des pixels de bord anticrénelés.

Point Géométrie visible de la couche supérieure Variation attendue du compteur
(120, 120) Trou central Below augmente une fois
(210, 120) Bande orange Ring augmente une fois
(10, 10) À l’extérieur du cercle extérieur de l’anneau Below augmente une fois

Le rayon extérieur est de 120 points. L’encart intérieur est de 72 points de chaque côté, ce qui laisse un rayon intérieur de 48 points. Les trois points de test se trouvent respectivement à 0, 90 et environ 155,6 points du centre. J’ai vérifié ce calcul géométrique déterministe avec Python ; le code de l’app SwiftUI a été examiné, mais n’a pas pu être compilé ni exécuté dans cet environnement, faute d’une chaîne d’outils Swift.

Cette séquence de tests constitue aussi un diagnostic utile. Si la bande orange ne réagit pas, vérifiez le geste de l’anneau et ses modificateurs de participation. Si le centre déclenche l’anneau, vérifiez sa forme de contenu et le paramètre pair-impair. Si aucune couche ne réagit dans le trou, supprimez temporairement les autres superpositions, puis rétablissez-les une à une pour repérer celle qui modifie le routage.

Pour vérifier directement la géométrie dans votre propre code, Path.contains(_:eoFill:) prend en charge le même choix pair-impair. Transmettez un point exprimé dans le système de coordonnées du chemin construit. Un test géométrique peut ainsi poser la même question — à l’intérieur ou à l’extérieur — sans considérer une capture d’écran comme le modèle d’interaction. La documentation d’Apple sur la contenance d’un chemin décrit cette opération.

N’utiliser le hit-test AppKit qu’à une frontière dont vous êtes responsable

Lorsqu’une superposition est déjà implémentée dans AppKit, NSView.hitTest(_:) est le point de personnalisation documenté. Cette méthode renvoie le descendant sélectionné pour un point et sert principalement à choisir le destinataire du clic de souris. Le point d’entrée est exprimé dans les coordonnées de la supervue du récepteur ; convertissez-le donc avant de le comparer à la géométrie locale. La documentation d’Apple sur le hit-test de NSView précise ces détails.

Pour une vue personnalisée dont vous êtes responsable, la redéfinition peut rejeter les points situés hors de sa zone d’interaction et transmettre les points acceptés à super.hitTest(_:). L’extrait suivant est un fragment de méthode pour une sous-classe NSView personnalisée existante, et non une seconde app complète. Il utilise RingShape, défini plus haut, et nécessite import AppKit en plus de import SwiftUI.

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)
}

N’utilisez cet extrait que si le contenu visible de la vue personnalisée et sa sous-arborescence suivent réellement la géométrie de cet anneau. Renvoyer nil rejette cette sous-arborescence pour le point concerné ; cela ne désigne pas une destination et n’ordonne pas à SwiftUI d’activer une vue sœur précise. La possibilité pour la couche inférieure voulue de recevoir le clic dépend toujours de la hiérarchie environnante. Une vue englobante qui continue d’accepter le point peut rester un obstacle.

Pour intégrer cette vue AppKit dont vous êtes responsable dans SwiftUI, utilisez NSViewRepresentable pour la créer et mettre à jour sa configuration. Synchronisez l’état des éléments et la géométrie d’interaction via la méthode de mise à jour du représentable ; utilisez un coordinateur pour relayer les messages de délégué ou de cible-action. Apple indique également aux développeurs de laisser frame et bounds de la vue racine représentée sous le contrôle de la disposition de SwiftUI. La documentation d’Apple sur NSViewRepresentable décrit ces limites de cycle de vie et de responsabilité.

Préserver l’événement d’origine et le propriétaire du geste

Effectuez la décision de routage pendant le hit-test. Évitez d’intercepter un clic sur la couche supérieure pour appeler manuellement l’action du contrôle inférieur : vous remplaceriez ainsi l’interaction normale du contrôle inférieur par une commande de l’app. De même, cet exemple ne nécessite ni retransmission d’événements, ni parcours des vues internes de SwiftUI, ni remplacement du gestionnaire de geste de la couche inférieure. Chaque couche interactive visible reste responsable de son propre comportement.

Cette approche concerne l’activation habituelle avec le bouton principal dans la hiérarchie de vues de l’app. Un clic réussi au centre ne garantit pas le comportement du défilement, du glissement, du survol ou des menus contextuels ; testez-les séparément si votre interface les ajoute. En particulier, Apple distingue la forme de contenu utilisée pour l’interaction de celles utilisées pour les aperçus de glissement et de menu contextuel, qui ne modifient pas le hit-test. La documentation d’Apple sur les formes de contenu explique ces types distincts.

Pour cette tâche sur ordinateur, gardez le chemin de l’anneau comme définition commune de sa bande visible et de sa zone cliquable. Le centre transparent a ainsi une signification précise : la couche supérieure rejette le point, et le contrôle SwiftUI inférieur reste responsable de la réponse via le système d’interaction normal.

Sources