To click a SwiftUI control through a transparent hole in an overlapping view, give the upper view an explicit hit-test shape that excludes the hole. This walkthrough builds that interaction inside one Mac app, using an in-memory ring and a button beneath it.
The concrete task is a small desktop drawing interface: clicking the orange ring selects the ring; clicking its transparent center activates the blue control visible underneath. Both layers belong to the same SwiftUI view hierarchy. The example uses geometry shared by drawing and hit testing, so it needs no image files or pixel sampling.
Choose the hit-test rule before changing event handling
Apple documents two relevant SwiftUI controls. allowsHitTesting(_:) determines whether a view participates in hit testing at all. contentShape(_:eoFill:) specifies the shape used for hit testing. Use the first for decoration that should never receive clicks; use the second for an interactive overlay with holes. Apple’s hit-testing participation documentation and content-shape documentation describe these separate responsibilities.
| Upper-layer requirement | Implementation choice |
|---|---|
| Decoration should ignore every click | Apply .allowsHitTesting(false) to that decoration |
| A circle should respond inside its outline | Give its interactive view a circular content shape |
| A ring should respond around a transparent center | Use a compound shape and even-odd filling for drawing and hit testing |
| An owned AppKit subtree needs selective participation | Override its hitTest(_:) boundary and reject points outside its interaction region |
These APIs describe participation and geometry; they do not specify an automatic test of the final rendered pixel’s alpha. For synthetic shapes, the supported path is to describe the intended interaction region explicitly. A transparent appearance alone is therefore an incomplete interaction specification. This distinction follows from the documented Boolean and shape-based interfaces, rather than a promise about every rendering modifier.
Build a complete SwiftUI ring-over-button example
Create a macOS app in Xcode using SwiftUI, and set its deployment target to macOS 12 or later. Replace the generated app declaration and initial view with the complete program below, leaving only one @main declaration in the target. The example uses .contentShape(.interaction, …), whose interaction kind is available from macOS 12 and explicitly controls hit testing. Apple’s interaction documentation identifies that behavior and availability.
The ring contains two concentric elliptical subpaths. Drawing and interaction both use the even-odd rule, making the inner ellipse a hole. Apple documents Shape.path(in:) as the geometry within the supplied rectangle and FillStyle as the rendering configuration, including even-odd filling. Shape geometry and FillStyle provide the underlying contracts.
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()
}
}
}
The lower button remains responsible for its own action. The ring’s gesture increments a different counter. Those trailing closures express the two actions directly; Swift’s language guide documents closures as functionality that can capture surrounding values. The Swift Programming Language: Closures explains the syntax and capture behavior.
The gray outline is decoration, so its hit testing is disabled individually. Keep that modifier on the outline: applying it to a container holding the interactive layers would give the container the wrong participation policy. The ring itself must remain eligible for interaction on its orange band.
Why the same fill rule matters twice
The drawing call and the content-shape modifier each receive eoFill: true. Setting it only for drawing would describe the visible hole without explicitly giving hit testing the same rule. Apple documents the content-shape parameter separately from rendering, so both declarations belong in this example. The content-shape modifier defines the interaction shape and its winding rule.
Keep dimensions and transformations aligned as well. Here, the ring’s drawing and content shape share a 240-point square. If you later move or scale the entire ring view, keep those operations together. If instead you change only a drawing path inside a larger container, update the interaction geometry to describe the same intended region.
Verify routing with three specific clicks
Activate the app window, then click well away from shape boundaries. The following points are illustrative local coordinates inside the 240-point square, measured from its upper-left corner. They distinguish the center hole, orange band, and transparent corner without relying on antialiased edge pixels.
| Point | Visible upper-layer geometry | Expected counter change |
|---|---|---|
(120, 120) |
Center hole | Below increases once |
(210, 120) |
Orange band | Ring increases once |
(10, 10) |
Outside the ring’s outer circle | Below increases once |
The outer radius is 120 points. The inner inset is 72 points on each side, leaving an inner radius of 48 points. The three test points are respectively 0, 90, and approximately 155.6 points from the center. I checked this deterministic geometry calculation with Python; the SwiftUI app was inspected but could not be compiled or run in this environment because a Swift toolchain was unavailable.
This test is also a useful diagnostic sequence. If the orange band does not respond, inspect the ring’s gesture and participation modifiers. If the center triggers the ring, inspect its content shape and even-odd parameter. If neither layer responds in the hole, temporarily remove additional overlays, then restore them one at a time to identify which layer changes routing.
For direct geometry checks in your own code, Path.contains(_:eoFill:) supports the same even-odd choice. Pass a point expressed in the coordinate system of the path you constructed. This lets a geometry test ask the same inside-or-outside question without treating a screenshot as the interaction model. Apple’s path-containment documentation describes this operation.
Use AppKit hit testing only at a boundary you own
When an overlay is already implemented in AppKit, NSView.hitTest(_:) is the documented customization point. It returns the descendant selected for a point and is used primarily to choose the mouse-down recipient. Its input point is in the receiver’s superview coordinates, so convert it before comparing against local geometry. Apple’s NSView hit-test documentation specifies these details.
For an owned custom view, the override can reject points outside its interaction region and defer accepted points to super.hitTest(_:). The following is a method fragment for an existing custom NSView subclass, rather than a second complete app. It uses the RingShape defined above and requires import AppKit alongside 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)
}
Use this fragment only where the custom view’s visible content and subtree actually follow that ring geometry. Returning nil rejects that subtree for the point; it does not name a destination or command SwiftUI to activate a particular sibling. Whether the intended lower layer can receive the click still depends on the surrounding hierarchy. An enclosing view that continues to accept the point can remain an obstacle.
When embedding that owned AppKit view in SwiftUI, use NSViewRepresentable to create it and update its configuration. Keep item state and interaction geometry synchronized through the representable’s update method; use a coordinator when bridging delegate or target-action messages. Apple also directs developers to leave the represented root view’s frame and bounds under SwiftUI’s layout control. Apple’s NSViewRepresentable documentation describes these lifecycle and ownership boundaries.
Preserve the original event and gesture owner
Perform the routing decision during hit testing. Avoid catching an upper-layer click and manually invoking the lower control’s action: that would substitute an application command for the lower control’s normal interaction. Likewise, this example needs no event reposting, traversal of SwiftUI’s implementation views, or replacement of a lower layer’s gesture handler. Each visible interactive layer owns its own behavior.
The scope here is ordinary primary-button activation inside the app’s view hierarchy. A successful center click does not establish behavior for scrolling, dragging, hover, or context menus; test those separately if your interface adds them. In particular, Apple distinguishes the interaction content shape from drag-preview and context-menu-preview shapes, which do not change hit testing. Apple’s content-shape documentation explains those separate kinds.
For this desktop task, keep the ring’s path as the shared definition of its visible band and clickable region. That gives the transparent center a precise meaning: the upper layer declines the point, and the lower SwiftUI control remains responsible for responding through the normal interaction system.
Sources
allowsHitTesting(_:) — Apple
contentShape(_:eoFill:) — Apple
interaction — Apple
path(in:) — Apple
FillStyle — Apple
Closures — Swift.org
contentShape(::eoFill:) — Apple
contains(_:eoFill:) — Apple
hitTest(_:) — Apple
NSViewRepresentable — Apple