NSHostingView with SwiftUI gestures not receiving mouse events behind another NSHostingView on macOS 26

4 weeks ago 28
ARTICLE AD BOX

Issue Description (Swift / AppKit on macOS Tahoe 26.2, M1 Pro):

I’m new to Swift/AppKit and ran into an issue with a multi-layered UI setup. Here’s the structure of my interface:

Bottom layer: An AppKit view NSViewRepresentable.

Middle layer: A single SwiftUI view wrapped in an NSHostingView, which has a drag gesture.

Top layer: Another NSHostingView.

Problem:

The middle NSHostingView no longer responds to mouse clicks or drag gestures.

However, it still passes mouse events down to the bottom AppKit view.

If I remove the top NSHostingView, the middle layer starts receiving mouse events again.

Observations:

I also found an older similar issue https://developer.apple.com/forums/thread/759081 but I couldn't make the proposed solution work for me.

Replacing the middle layer with an AppKit NSView and implementing a custom drag gesture works correctly, but this is too much and it was breaking some of other features in my app.

This issue occurs only on macOS Tahoe 26.2. It works fine on macOS Sequoia.

I checked the macOS 26 release notes but found nothing directly related to this behavior.

Reproducible code:

I created a demo to reproduce this behavior. For convenience, I put everything together in one file. I would appreciate any help. Thanks!

// // 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() }
Read Entire Article