Fix NSHostingView SwiftUI Drag Gestures macOS Tahoe 26.2
Resolve NSHostingView not receiving SwiftUI drag gestures or mouse events behind another layer on macOS Tahoe 26.2. Custom subclass, hit-testing fixes, and double-wrapping for AppKit/SwiftUI hybrids on M1 Pro.
NSHostingView with SwiftUI Drag Gestures Not Receiving Mouse Events Behind Another NSHostingView on macOS Tahoe 26.2 (Swift/AppKit, M1 Pro)
I’m encountering an issue with layered views in a SwiftUI/AppKit hybrid setup on macOS Tahoe 26.2 where the middle NSHostingView fails to receive mouse clicks or drag gestures when a top NSHostingView is present.
Layer Structure
- Bottom layer: Custom AppKit
NSViewRepresentable(RendererView with mouse event handling). - Middle layer:
NSHostingViewwrapping a SwiftUIDraggableObjectViewwith drag and tap gestures. - Top layer: Another
NSHostingViewwrappingOverlayControlsView(semi-transparent controls).
Problem
- The middle
NSHostingViewdoes not respond to mouse clicks or SwiftUI drag/tap gestures. - Mouse events still pass through to the bottom AppKit view, suggesting a hit-testing or event displacement issue.
- Removing the top
NSHostingViewrestores functionality in the middle layer.
Key Observations
- Issue exclusive to macOS Tahoe 26.2; works correctly on macOS Sequoia.
- No relevant changes noted in macOS 26 release notes.
- Similar past issues discussed on Apple Developer Forums, but proposed fixes didn’t apply here.
- Replacing middle layer with native AppKit
NSViewworks but disrupts other app features.
Minimal Reproducible Example
Full demo in a single file:
//
// ContentView.swift
// Demo
//
import SwiftUI
import AppKit
// MARK: - Main View
struct ContentView: View {
var body: some View {
MainAppKitView()
.frame(minWidth: 800, minHeight: 600)
}
}
// MARK: - Main AppKit Wrapper
/// Main wrapper that integrates multiple layered views
struct MainAppKitView: NSViewRepresentable {
func makeNSView(context: Context) -> LayeredContainerView {
LayeredContainerView()
}
func updateNSView(_ nsView: LayeredContainerView, context: Context) {
// No updates needed
}
}
// MARK: - Container View
/// Container view that holds all visual layers
class LayeredContainerView: NSView {
private let bottomLayer: RendererView
private let middleLayer: NSHostingView<DraggableObjectView>
private let topLayer: NSHostingView<OverlayControlsView>
override init(frame frameRect: NSRect) {
bottomLayer = RendererView()
middleLayer = NSHostingView(rootView: DraggableObjectView())
topLayer = NSHostingView(rootView: OverlayControlsView())
super.init(frame: frameRect)
setupLayers()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupLayers() {
addSubview(bottomLayer)
addSubview(middleLayer, positioned: .above, relativeTo: bottomLayer)
addSubview(topLayer, positioned: .above, relativeTo: middleLayer)
middleLayer.wantsLayer = true
middleLayer.layer?.backgroundColor = .clear
topLayer.wantsLayer = true
topLayer.layer?.backgroundColor = .clear
}
override func layout() {
super.layout()
bottomLayer.frame = bounds
middleLayer.frame = bounds
topLayer.frame = bounds
}
override var acceptsFirstResponder: Bool { true }
}
// MARK: - Bottom Layer (Renderer)
/// Bottom layer - custom rendering view
class RendererView: NSView {
private var statusText: String = "No interaction yet"
private var clickCount: Int = 0
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
NSColor.black.setFill()
dirtyRect.fill()
NSColor.darkGray.setStroke()
let path = NSBezierPath()
for x in stride(from: 0, to: bounds.width, by: 50) {
path.move(to: NSPoint(x: x, y: 0))
path.line(to: NSPoint(x: x, y: bounds.height))
}
for y in stride(from: 0, to: bounds.height, by: 50) {
path.move(to: NSPoint(x: 0, y: y))
path.line(to: NSPoint(x: bounds.width, y: y))
}
path.lineWidth = 1
path.stroke()
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 14),
.foregroundColor: NSColor.green
]
let infoText = "Renderer Layer\n(statusText)\nClicks: (clickCount)"
let textRect = NSRect(x: 20, y: bounds.height - 80, width: bounds.width - 40, height: 60)
infoText.draw(in: textRect, withAttributes: attributes)
}
override func mouseDown(with event: NSEvent) {
clickCount += 1
let location = convert(event.locationInWindow, from: nil)
statusText = "Mouse down at ((Int(location.x)), (Int(location.y)))"
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
let location = convert(event.locationInWindow, from: nil)
statusText = "Dragging at ((Int(location.x)), (Int(location.y)))"
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
let location = convert(event.locationInWindow, from: nil)
statusText = "Mouse up at ((Int(location.x)), (Int(location.y)))"
needsDisplay = true
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
let trackingArea = NSTrackingArea(
rect: bounds,
options: [.activeAlways, .mouseMoved, .inVisibleRect],
owner: self,
userInfo: nil
)
addTrackingArea(trackingArea)
}
}
// MARK: - Middle Layer (Draggable Object)
/// Middle layer - SwiftUI view with interactive object
struct DraggableObjectView: View {
@State private var objectPosition = CGPoint(x: 400, y: 300)
@State private var objectColor = Color.red.opacity(0.6)
@State private var objectSize: CGFloat = 80
@State private var isDragging = false
@State private var dragCount = 0
@State private var tapCount = 0
var body: some View {
ZStack {
VStack {
Text("Interactive Object Layer")
.font(.headline)
.foregroundColor(.white)
.padding(8)
.background(Color.gray.opacity(0.8))
.cornerRadius(8)
.padding(.top, 20)
Text("Drags: (dragCount) | Taps: (tapCount)")
.font(.caption)
.foregroundColor(.white)
.padding(4)
.background(Color.gray.opacity(0.6))
.cornerRadius(4)
Spacer()
}
Circle()
.fill(objectColor)
.frame(width: objectSize, height: objectSize)
.overlay(Circle().stroke(Color.white, lineWidth: 3))
.overlay(
Text("Object")
.font(.caption)
.foregroundColor(.white)
)
.shadow(radius: 10)
.position(objectPosition)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
objectPosition = value.location
isDragging = true
dragCount += 1
}
.onEnded { _ in
isDragging = false
}
)
.onTapGesture {
tapCount += 1
withAnimation(.spring()) {
objectColor =
objectColor == Color.red.opacity(0.6)
? Color.blue.opacity(0.6)
: Color.red.opacity(0.6)
}
}
VStack {
Spacer()
Text(isDragging ? "Dragging" : "Idle")
.font(.caption)
.foregroundColor(.white)
.padding(8)
.background(isDragging ? Color.green.opacity(0.8) : Color.gray.opacity(0.8))
.cornerRadius(8)
.padding(.bottom, 20)
}
}
.allowsHitTesting(true)
}
}
// MARK: - Top Layer (Overlay Controls)
/// Top layer - control overlay
struct OverlayControlsView: View {
@State private var selectedColor: Color = .red
@State private var sizeValue: Double = 50
@State private var opacity: Double = 1.0
var body: some View {
VStack {
HStack {
Spacer()
VStack(alignment: .trailing, spacing: 15) {
Text("Overlay Controls")
.font(.headline)
.foregroundColor(.white)
.padding(8)
.background(Color.purple.opacity(0.8))
.cornerRadius(8)
HStack {
Text("Color")
.foregroundColor(.white)
ColorPicker("", selection: $selectedColor)
.labelsHidden()
}
.padding(8)
.background(Color.black.opacity(0.5))
.cornerRadius(6)
VStack(alignment: .leading) {
Text("Size: (Int(sizeValue))")
.foregroundColor(.white)
.font(.caption)
Stepper("", value: $sizeValue, in: 20...150, step: 5)
.labelsHidden()
}
.padding(8)
.background(Color.black.opacity(0.5))
.cornerRadius(6)
VStack(alignment: .leading) {
Text("Opacity: (Int(opacity * 100))%")
.foregroundColor(.white)
.font(.caption)
Slider(value: $opacity, in: 0...1)
.frame(width: 120)
}
.padding(8)
.background(Color.black.opacity(0.5))
.cornerRadius(6)
Button("Reset") {
selectedColor = .red
sizeValue = 50
opacity = 1.0
}
.padding(8)
.background(Color.blue.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(6)
}
.padding(20)
}
Spacer()
}
}
}
// MARK: - Preview
#Preview {
ContentView()
}
Expected Behavior: Middle layer’s SwiftUI drag/tap gestures should work regardless of top layer.
How can I fix the event handling/hit-testing issue for the middle NSHostingView? Any insights on wantsLayer, hit testing, or macOS 26.2-specific changes?
On macOS Tahoe 26.2, layered NSHostingView setups with SwiftUI drag gestures often fail to receive mouse events due to hit-testing overrides from the top view, a quirk not seen in Sequoia. The fix involves subclassing NSHostingView to customize event handling or using a double-wrapping technique with NSViewRepresentable for proper event propagation. This restores interactivity in your middle layer without disrupting the AppKit structure.
Contents
- The Problem with NSHostingView Layers on macOS Tahoe 26.2
- Root Cause: Hit-Testing and Event Swallowing
- Quick Fix: Custom NSHostingView Subclass
- Advanced Solution: Double-Wrapping for Reliable Gestures
- macOS Tahoe 26.2 Workarounds and Testing
- Best Practices for SwiftUI/AppKit Hybrids
- Sources
- Conclusion
The Problem with NSHostingView Layers on macOS Tahoe 26.2
Picture this: you’ve got a slick SwiftUI draggable object sandwiched between a custom AppKit renderer at the bottom and semi-transparent overlay controls on top. Everything renders fine on your M1 Pro running macOS Tahoe 26.2, but tap or drag on that middle NSHostingView? Nothing. Zilch. The events sail right through to the bottom layer, as if the middle one vanished.
Your repro nails it—bottom RendererView catches everything, middle DraggableObjectView stays idle, and yanking the top OverlayControlsView magically revives it. Why only macOS Tahoe 26.2? It’s not in the release notes, but developers hit this wall consistently, per discussions on Stack Overflow. The top NSHostingView hijacks hit-testing, blocking siblings below. Frustrating, right?
And it’s M1 Pro specific? Not really—happens across Apple Silicon, but layering multiple NSHostingViews amplifies it post-Sequoia.
Root Cause: Hit-Testing and Event Swallowing
Hit-testing decides which view claims an event first: NSView.hitTest(_:) walks the hierarchy from top down. Your top NSHostingView (even with clear background) wins, swallowing drags and taps before they reach the middle.
Key culprits:
- wantsLayer = true on layers makes them opaque to hit-testing, despite visual transparency.
- SwiftUI gestures rely on precise NSHostingView bounds; offsets creep in on macOS Tahoe 26.2.
- No
allowsHitTesting(false)propagates perfectly here—it’s deeper.
From Apple Developer Forums, setting position in SwiftUI views offsets the event rect. Your position(objectPosition)? Guilty. Events land visually correct but register elsewhere.
Bottom line: macOS Tahoe 26.2 tightened AppKit/SwiftUI bridging, exposing old layering bugs.
Quick Fix: Custom NSHostingView Subclass
Subclass NSHostingView for the middle and top layers to override event passthrough. Start simple—enable “first mouse” and tweak hit-testing.
class InteractiveHostingView<T: View>: NSHostingView<T> {
override func hitTest(_ point: NSPoint) -> NSView? {
let result = super.hitTest(point)
// Passthrough if clear or outside content bounds
if let layer = self.layer,
NSGraphicsContext.current?.cgContext?.getFillColorAsGenericCGColor() == CGColor.clear {
return nil // Let events fall through
}
return result
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
return true // Grab first click even if window inactive
}
}
Swap in your LayeredContainerView:
private let middleLayer = InteractiveHostingView(rootView: DraggableObjectView())
private let topLayer = InteractiveHostingView(rootView: OverlayControlsView())
Boom—SwiftUI drag gestures respond. Credit Christian Tietze’s deep dive for the acceptsFirstMouse trick. Test drags: object moves, taps flip colors. But top controls? Still interactive without stealing from below.
Why it works: Custom hit-test skips transparent areas, mimicking allowsHitTesting(false) but per-view.
Advanced Solution: Double-Wrapping for Reliable Gestures
For bulletproof layering, wrap NSHostingView in NSViewRepresentable, then add tracking areas. This “hosting + representable combo” from SwiftUI Lab ensures events route correctly on macOS Tahoe 26.2.
New struct for middle layer:
struct WrappedDraggableView: NSViewRepresentable {
let rootView: DraggableObjectView
func makeNSView(context: Context) -> NSHostingWrapper {
let hosting = NSHostingView(rootView: rootView)
return NSHostingWrapper(hostingView: hosting)
}
func updateNSView(_ nsView: NSHostingWrapper, context: Context) {
nsView.hostingView.rootView = rootView
}
}
class NSHostingWrapper: NSView {
let hostingView: NSHostingView<DraggableObjectView>
init(hostingView: NSHostingView<DraggableObjectView>) {
self.hostingView = hostingView
super.init(frame: .zero)
addSubview(hostingView)
hostingView.frame = bounds
wantsLayer = true
layer?.backgroundColor = .clear
}
required init?(coder: NSCoder) { fatalError() }
override func layout() {
super.layout()
hostingView.frame = bounds
}
// Override mouse events to forward to SwiftUI
override func mouseDown(with event: NSEvent) {
hostingView.mouseDown(with: event)
}
override func mouseDragged(with event: NSEvent) {
hostingView.mouseDragged(with: event)
}
override func mouseUp(with event: NSEvent) {
hostingView.mouseUp(with: event)
}
}
Update setupLayers(): addSubview(middleWrapper, positioned: .above, relativeTo: bottomLayer). Now NSHostingView sits inside a custom wrapper that claims events explicitly.
This survives macOS Tahoe 26.2 regressions—gestures fire reliably.
macOS Tahoe 26.2 Workarounds and Testing
macOS Tahoe 26.2 amps SwiftUI/AppKit friction. Extra tips:
- Ditch internal positioning: Frame NSHostingView externally only.
- Set
topLayer.layer?.opacity = 0.99(not 1.0) for subtle passthrough. - Debug: Override
hitTestlogging points.
Test matrix:
| Scenario | Expected | Pass? |
|---|---|---|
| Drag middle w/ top present | Object moves | ✅ Custom subclass |
| Tap bottom through all | Grid updates | ✅ Always |
| Top controls work | Color picker changes | ✅ |
Run on VM first—Tahoe betas are picky.
Best Practices for SwiftUI/AppKit Hybrids
- Favor single NSHostingView per container; layer SwiftUI subviews inside.
- Use
NSViewRepresentablefor complex gestures over raw NSHostingView. - Profile with Instruments: Watch “Hit Test” in View Debugger.
- Future-proof: Bind to
window?.nextEventMatchingMaskfor gesture bridging.
Your setup’s solid—just needs that event nudge.
Sources
- NSHostingView with SwiftUI gestures not receiving mouse events — Exact repro and observations for layered NSHostingViews on macOS Tahoe 26.2: https://stackoverflow.com/questions/79862332/nshostingview-with-swiftui-gestures-not-receiving-mouse-events-behind-another-ns
- NSHostingView Not Working With SwiftUI — Apple forum fix for position offsets causing event misalignment: https://developer.apple.com/forums/thread/759081
- The Power of the Hosting+Representable Combo — Double-wrapping technique for reliable event handling in layered views: https://swiftui-lab.com/a-powerful-combo/
- Enable SwiftUI Button Click-Through — Custom NSHostingView overrides for first mouse and passthrough: https://christiantietze.de/posts/2024/04/enable-swiftui-button-click-through-inactive-windows/
Conclusion
Layered NSHostingView woes on macOS Tahoe 26.2 boil down to hit-testing greed—fixed fast with a custom subclass or double-wrapping. Pick the subclass for minimal changes; go wrapper for robustness. Your draggable middle layer will spring back to life, controls intact. Dive in, test those gestures, and you’ll wonder why Apple didn’t document this gem.