Om du vill klicka på ett SwiftUI-kontrollelement genom ett genomskinligt hål i en överlappande vy ger du den övre vyn en uttrycklig träfftestningsform som utesluter hålet. Den här genomgången bygger upp interaktionen i en enda Mac-app med en ring som skapas i minnet och en knapp under den.

Den konkreta uppgiften är ett litet gränssnitt för ritning på datorn: ett klick på den orange ringen väljer ringen, medan ett klick i dess genomskinliga mitt aktiverar det blå kontrollen som syns under den. Båda lagren tillhör samma SwiftUI-vyhierarki. Exemplet använder geometri som delas av ritning och träfftestning, så det behövs varken bildfiler eller pixelsampling.

Välj träfftestningsregel innan du ändrar händelsehanteringen

Apple dokumenterar två relevanta SwiftUI-kontroller. allowsHitTesting(_:) avgör om en vy över huvud taget deltar i träfftestning. contentShape(_:eoFill:) anger formen som används för träfftestning. Använd den första för dekoration som aldrig ska ta emot klick och den andra för ett interaktivt överlägg med hål. Apples dokumentation om deltagande i träfftestning och dokumentationen om innehållsformer beskriver dessa separata ansvarsområden.

Krav på det övre lagret Implementeringsval
Dekoration ska ignorera alla klick Tillämpa .allowsHitTesting(false) på dekorationen
En cirkel ska reagera innanför sin kontur Ge den interaktiva vyn en cirkelformad innehållsform
En ring ska reagera runt en genomskinlig mitt Använd en sammansatt form och jämn-udda-fyllning för både ritning och träfftestning
Ett ägt AppKit-underträd kräver selektivt deltagande Åsidosätt dess gräns för hitTest(_:) och avvisa punkter utanför interaktionsområdet

Dessa API:er beskriver deltagande och geometri; de anger inte att den slutliga renderade pixelns alfavärde testas automatiskt. För syntetiska former är den stödda metoden att uttryckligen beskriva det avsedda interaktionsområdet. Ett genomskinligt utseende räcker därför inte som specifikation av interaktionen. Den här skillnaden följer av de dokumenterade booleska och formbaserade gränssnitten, inte av något löfte om varje renderingsmodifierare.

Skapa ett komplett SwiftUI-exempel med en ring över en knapp

Skapa en macOS-app i Xcode med SwiftUI och ange macOS 12 eller senare som lägsta systemversion. Ersätt den genererade appdeklarationen och den inledande vyn med hela programmet nedan, så att målet bara innehåller en @main-deklaration. Exemplet använder .contentShape(.interaction, …), vars interaktionstyp finns från och med macOS 12 och uttryckligen styr träfftestning. Apples dokumentation om interaktion beskriver dess beteende och tillgänglighet.

Ringen består av två koncentriska elliptiska delbanor. Ritningen och interaktionen använder båda jämn-udda-regeln, vilket gör den inre ellipsen till ett hål. Apple dokumenterar Shape.path(in:) som geometrin inom den angivna rektangeln och FillStyle som renderingskonfigurationen, inklusive jämn-udda-fyllning. Formgeometri och FillStyle beskriver de underliggande reglerna.

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

Den nedre knappen ansvarar fortfarande för sin egen åtgärd. Ringens gest ökar en annan räknare. Dessa avslutande closures uttrycker de två åtgärderna direkt; i Swifts språkguiden dokumenteras closures som funktionalitet som kan fånga omgivande värden. The Swift Programming Language: Closures förklarar syntaxen och fångstbeteendet.

Den grå konturen är dekoration, så träfftestning inaktiveras enbart för den. Behåll modifieraren på konturen: om du tillämpar den på en behållare med de interaktiva lagren får behållaren fel policy för deltagande. Själva ringen måste fortfarande kunna ta emot interaktion på det orange bandet.

Varför samma fyllningsregel behövs två gånger

Både ritningsanropet och modifieraren för innehållsformen får eoFill: true. Om du bara ställer in det för ritningen beskrivs det synliga hålet, men träfftestningen får inte uttryckligen samma regel. Apple dokumenterar parametern för innehållsformen separat från renderingen, så båda deklarationerna behövs i exemplet. Modifieraren för innehållsformen definierar interaktionsformen och dess vindningsregel.

Se också till att dimensioner och transformationer är synkroniserade. Här delar ringens ritning och innehållsform en kvadrat på 240 punkter. Om du senare flyttar eller skalar hela ringvyn håller du de operationerna samlade. Om du i stället ändrar enbart en ritningsbana inuti en större behållare uppdaterar du interaktionsgeometrin så att den beskriver samma avsedda område.

Verifiera dirigeringen med tre specifika klick

Aktivera appfönstret och klicka sedan en bra bit från formgränserna. Följande punkter är exempel på lokala koordinater i kvadraten på 240 punkter, mätta från dess övre vänstra hörn. De skiljer mellan mitthålet, det orange bandet och det genomskinliga hörnet utan att förlita sig på kantpixlar med kantutjämning.

Punkt Synlig geometri i det övre lagret Förväntad ändring av räknare
(120, 120) Mitthål Below ökar en gång
(210, 120) Orange band Ring ökar en gång
(10, 10) Utanför ringens yttre cirkel Below ökar en gång

Den yttre radien är 120 punkter. Den inre förskjutningen är 72 punkter på varje sida, vilket ger en innerradie på 48 punkter. De tre testpunkterna ligger 0, 90 respektive cirka 155,6 punkter från mitten. Jag kontrollerade den deterministiska geometriberäkningen med Python; SwiftUI-appen granskades men kunde inte kompileras eller köras i den här miljön eftersom en Swift-verktygskedja saknades.

Det här testet är också en användbar felsökningssekvens. Om det orange bandet inte reagerar granskar du ringens gest- och deltagandemodifierare. Om mitten aktiverar ringen granskar du dess innehållsform och parametern för jämn-udda-fyllning. Om inget av lagren reagerar i hålet tar du tillfälligt bort ytterligare överlägg och lägger sedan tillbaka dem ett i taget för att hitta vilket lager som ändrar dirigeringen.

För direkta geometrikontroller i din egen kod stöder Path.contains(_:eoFill:) samma val av jämn-udda-regel. Skicka in en punkt uttryckt i koordinatsystemet för banan du skapade. Då kan ett geometriskt test ställa samma fråga om innanför eller utanför utan att behandla en skärmbild som interaktionsmodellen. Apples dokumentation om banors innehåll beskriver den här operationen.

Använd AppKit-träfftestning endast vid en gräns du äger

När ett överlägg redan har implementerats i AppKit är NSView.hitTest(_:) den dokumenterade anpassningspunkten. Den returnerar den underordnade vy som valts för en punkt och används främst för att välja mottagare av musnedtryckningen. Indatapunkten anges i mottagarens supervys koordinater, så konvertera den innan du jämför den med lokal geometri. Apples dokumentation om NSViews träfftestning anger dessa detaljer.

För en egen anpassad vy kan åsidosättningen avvisa punkter utanför interaktionsområdet och låta super.hitTest(_:) hantera godkända punkter. Följande är ett metodfragment för en befintlig anpassad NSView-underklass, inte en andra komplett app. Det använder RingShape som definierades ovan och kräver import AppKit tillsammans med 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)
}

Använd det här fragmentet endast när den anpassade vyns synliga innehåll och underträd faktiskt följer ringens geometri. Om du returnerar nil avvisas underträdet för punkten; det anger inte någon destination och instruerar inte SwiftUI att aktivera ett visst syskon. Om det avsedda nedre lagret kan ta emot klicket beror fortfarande på den omgivande hierarkin. En omslutande vy som fortsätter att godkänna punkten kan fortfarande vara ett hinder.

När du bäddar in den AppKit-vy du äger i SwiftUI använder du NSViewRepresentable för att skapa den och uppdatera dess konfiguration. Håll objekttillstånd och interaktionsgeometri synkroniserade genom representerbarens uppdateringsmetod; använd en koordinator när du förmedlar delegat- eller målåtgärdsmeddelanden. Apple rekommenderar också att utvecklare låter SwiftUI styra frame och bounds för den representerade rotvyn. Apples dokumentation om NSViewRepresentable beskriver dessa livscykel- och ägargränser.

Bevara den ursprungliga händelse- och gestägaren

Utför dirigeringsbeslutet under träfftestningen. Undvik att fånga ett klick i det övre lagret och manuellt anropa det nedre kontrollelementets åtgärd: då ersätter du den nedre kontrollens vanliga interaktion med ett programkommando. Exemplet kräver inte heller att händelser skickas vidare, att SwiftUIs implementeringsvyer genomsöks eller att en lägre nivås gestbehandlare ersätts. Varje synligt interaktivt lager äger sitt eget beteende.

Omfattningen här är vanlig aktivering med primär musknapp i appens vyhierarki. Ett fungerande klick i mitten fastställer inte beteendet för rullning, dragning, hovring eller kontextmenyer; testa dessa separat om gränssnittet innehåller dem. Apple skiljer särskilt mellan interaktionens innehållsform och former för förhandsvisning av drag och kontextmeny, som inte ändrar träfftestningen. Apples dokumentation om innehållsformer förklarar dessa separata typer.

För den här datoruppgiften låter du ringens bana vara den gemensamma definitionen av dess synliga band och klickbara område. Då får den genomskinliga mitten en tydlig innebörd: det övre lagret avvisar punkten och det nedre SwiftUI-kontrollelementet ansvarar fortfarande för att reagera genom det vanliga interaktionssystemet.

Källor