From 63fc8fe04c1cbb6370f154071eb01fc0b51c5533 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 15 Sep 2026 00:16:55 +0800 Subject: [PATCH] fix(hosting): restore macOS tap gestures in offset views --- .../Shims/AppKit/NSView_Private.h | 1 + .../Hosting/AppKit/View/NSHostingView.swift | 81 +++++++++ .../NSHostingViewRootTransformTests.swift | 172 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 Tests/OpenSwiftUITests/Integration/Hosting/AppKit/View/NSHostingViewRootTransformTests.swift diff --git a/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h b/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h index d21d36651..dd460547b 100644 --- a/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h +++ b/Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h @@ -17,6 +17,7 @@ @property (nonatomic) BOOL ignoreHitTest_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(ignoreHitTest); - (nullable NSResponder *)_nextResponderForEvent:(nullable NSEvent *)event; +- (nonnull id)_observerForChangesInGeometryInWindow:(void (^ _Nonnull)(NSView * _Nonnull view))block; - (void)_updateLayerGeometryFromView; - (void)_updateLayerShadowFromView; - (void)_updateLayerShadowColorFromView; diff --git a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift index 8589471dc..ba9d20410 100644 --- a/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift +++ b/Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift @@ -118,6 +118,10 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont private var hitTestEventMonitor: Any? + private var geometryChangeRegistrationPhase: HostingViewGeometryRegistrationPhase = .unregistered + + private var geometryChangeObserver: Any? + private lazy var eventBindingSource = BindingSource(hostingView: self) private lazy var eventBridge = AppKitEventBindingBridge( @@ -487,6 +491,17 @@ open class NSHostingView: NSView, XcodeViewDebugDataProvider where Cont } } + private func geometryInWindowDidChange() { + switch geometryChangeRegistrationPhase { + case .unregistered: + Log.internalError("Received geometry change with no registration for \(self).") + case .registering: + break + case .registered: + invalidateProperties(.transform, mayDeferUpdate: false) + } + } + open override func layout() { guard canAdvanceTimeAutomatically else { return @@ -1064,6 +1079,13 @@ extension NSHostingView: ViewRendererHost { viewGraph.setEnvironment(environment) } + package func updateTransform() { + if !viewGraph.invalidateTransform(), geometryChangeRegistrationPhase != .unregistered { + geometryChangeObserver = nil + geometryChangeRegistrationPhase = .unregistered + } + } + package func updateSize() { viewGraph.setProposedSize(bounds.size) } @@ -1088,6 +1110,8 @@ extension NSHostingView: ViewRendererHost { return unsafeBitCast(renderer, to: T.self) } else if EventGraphHost.self == T.self { return unsafeBitCast(self as any EventGraphHost, to: T.self) + } else if RootTransformProvider.self == T.self { + return unsafeBitCast(self as any RootTransformProvider, to: T.self) } else { return nil } @@ -1127,6 +1151,63 @@ extension NSHostingView: ViewRendererHost { } } +// MARK: - NSHostingView + RootTransformProvider [6.5.4] + +extension NSHostingView: RootTransformProvider { + package func rootTransform() -> ViewTransform { + if geometryChangeRegistrationPhase == .unregistered { + geometryChangeRegistrationPhase = .registering + geometryChangeObserver = _observerForChangesInGeometry { view in + (view as! Self).geometryInWindowDidChange() + } + geometryChangeRegistrationPhase = .registered + } + guard window != nil else { + return ViewTransform() + } + + var buffer = ViewTransform.UnsafeBuffer() + let usesRootGeometry = _SemanticFeature_v6.isEnabled + if !usesRootGeometry { + buffer.appendCoordinateSpace(id: hostingViewCoordinateSpace) + } + var origin = convert(CGPoint.zero, to: nil) + if _SemanticFeature_v3.isEnabled { + if _SemanticFeature_v3.isEnabled { + origin.y = (window?.frame.height ?? 0) - origin.y + } + buffer.appendTranslation(CGSize(width: -origin.x, height: -origin.y)) + } else { + buffer.appendAffineTransform( + CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: -origin.x, ty: origin.y), + inverse: false + ) + } + if usesRootGeometry { + buffer.appendScrollGeometry( + .rootViewTransform(contentOffset: .zero, containerSize: convert(bounds, to: nil).size), + isClipped: true + ) + buffer.appendScrollGeometry( + .rootViewTransform(contentOffset: .zero, containerSize: bounds.size), + isClipped: clipsToBounds + ) + buffer.appendCoordinateSpace(id: hostingViewCoordinateSpace) + } else { + _ = clipsToBounds + } + var transform = ViewTransform() + transform.append(movingContentsOf: &buffer) + return transform + } +} + +private enum HostingViewGeometryRegistrationPhase: Hashable { + case unregistered + case registering + case registered +} + @_spi(Private) @available(iOS, unavailable) @available(tvOS, unavailable) diff --git a/Tests/OpenSwiftUITests/Integration/Hosting/AppKit/View/NSHostingViewRootTransformTests.swift b/Tests/OpenSwiftUITests/Integration/Hosting/AppKit/View/NSHostingViewRootTransformTests.swift new file mode 100644 index 000000000..1a2544a54 --- /dev/null +++ b/Tests/OpenSwiftUITests/Integration/Hosting/AppKit/View/NSHostingViewRootTransformTests.swift @@ -0,0 +1,172 @@ +// +// NSHostingViewRootTransformTests.swift +// OpenSwiftUITests + +#if os(macOS) +import AppKit +import OpenAttributeGraphShims +@_spi(ForOpenSwiftUIOnly) +@testable import OpenSwiftUI +@_spi(ForOpenSwiftUIOnly) +import OpenSwiftUICore +import OpenSwiftUITestsSupport +import Testing + +@MainActor +@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc)) +struct NSHostingViewRootTransformTests { + @Test + func detachedHostProvidesIdentityTransform() throws { + let host = NSHostingView(rootView: EmptyView()) + let provider = try #require(host.as(RootTransformProvider.self)) + let transform = provider.rootTransform() + #expect(transform.convert(.globalToSpace(.local), point: CGPoint(x: 20, y: 15)) == CGPoint(x: 20, y: 15)) + } + + @Test(arguments: [ + (Semantics.v2, CGPoint(x: 160, y: 175)), + (Semantics.v3, CGPoint(x: 160, y: 225)), + (Semantics.v6, CGPoint(x: 160, y: 225)), + ]) + func windowCoordinatesReachHost(semantics: Semantics, globalPoint: CGPoint) throws { + try semantics.test { + let fixture = Fixture(rootView: EmptyView()) + defer { fixture.window.close() } + _ = try #require(fixture.host.as(RootTransformProvider.self)) + let transform = fixture.host.updateViewGraph { $0.transform } + #expect(transform.convert(.globalToSpace(.local), point: globalPoint) == CGPoint(x: 20, y: 15)) + #expect(transform.convert(.localToSpace(.global), point: CGPoint(x: 20, y: 15)) == globalPoint) + } + } + + @Test + func geometryChangesInvalidateRootTransform() throws { + try Semantics.v6.test { + let fixture = Fixture(rootView: EmptyView()) + defer { fixture.window.close() } + _ = try #require(fixture.host.as(RootTransformProvider.self)) + let initial = fixture.host.updateViewGraph { $0.transform } + #expect(initial.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 140, y: 210)) + + fixture.host.setFrameOrigin(CGPoint(x: 60, y: 40)) + #expect(fixture.host.propertiesNeedingUpdate.contains(.transform)) + let moved = fixture.host.updateViewGraph { $0.transform } + #expect(moved.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 160, y: 200)) + + fixture.parent.setFrameOrigin(CGPoint(x: 150, y: 100)) + #expect(fixture.host.propertiesNeedingUpdate.contains(.transform)) + let ancestorMoved = fixture.host.updateViewGraph { $0.transform } + #expect(ancestorMoved.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 210, y: 180)) + + fixture.host.removeFromSuperview() + let detached = fixture.host.updateViewGraph { $0.transform } + #expect(detached.convert(.localToSpace(.global), point: CGPoint(x: 20, y: 15)) == CGPoint(x: 20, y: 15)) + } + } + + @Test + func windowMouseEventBindsAfterAncestorMovement() throws { + try Semantics.v6.test { + let fixture = Fixture(rootView: Color.red.onTapGesture {}) + defer { fixture.window.close() } + let root = try #require(fixture.host.updateViewGraph { + $0.instantiateIfNeeded() + return $0.responderNode + }) + let event = MouseEvent( + timestamp: .zero, + button: .primary, + phase: .began, + location: .zero, + globalLocation: CGPoint(x: 160, y: 225), + modifiers: [] + ) + #expect(root.bindEvent(event) != nil) + + fixture.parent.setFrameOrigin(CGPoint(x: 300, y: 100)) + let movedRoot = try #require(fixture.host.updateViewGraph { $0.responderNode }) + #expect(movedRoot.bindEvent(event) == nil) + var movedEvent = event + movedEvent.globalLocation = CGPoint(x: 360, y: 205) + #expect(movedRoot.bindEvent(movedEvent) != nil) + } + } + + @Test + func geometryObserverDoesNotRetainHost() throws { + weak var weakHost: NSHostingView? + try autoreleasepool { + let host = NSHostingView(rootView: EmptyView()) + weakHost = host + let provider = try #require(host.as(RootTransformProvider.self)) + _ = provider.rootTransform() + } + #expect(weakHost == nil) + } + + @Test(arguments: [NSWindow.StyleMask.borderless, .titled]) + func nativeMouseClickRunsTapAction(styleMask: NSWindow.StyleMask) throws { + try Semantics.v6.test { + var tapCount = 0 + let fixture = Fixture(rootView: Color.red.onTapGesture { tapCount += 1 }, styleMask: styleMask) + defer { fixture.window.close() } + fixture.host.updateViewGraph { $0.instantiateIfNeeded() } + let recognizer = try #require(fixture.host.gestureRecognizers.first as? AppKitGestureRecognizer) + let location = fixture.host.convert(CGPoint(x: 20, y: 15), to: nil) + let down = try #require(NSEvent.mouseEvent( + with: .leftMouseDown, + location: location, + modifierFlags: [], + timestamp: 1, + windowNumber: fixture.window.windowNumber, + context: nil, + eventNumber: 1, + clickCount: 1, + pressure: 1 + )) + let up = try #require(NSEvent.mouseEvent( + with: .leftMouseUp, + location: location, + modifierFlags: [], + timestamp: 1.1, + windowNumber: fixture.window.windowNumber, + context: nil, + eventNumber: 2, + clickCount: 1, + pressure: 0 + )) + Update.perform { + recognizer.mouseDown(with: down) + recognizer.mouseUp(with: up) + } + #expect(tapCount == 1) + } + } + + @MainActor + private struct Fixture { + let window: NSWindow + let parent: NSView + let host: NSHostingView + + init(rootView: Content, styleMask: NSWindow.StyleMask = .borderless) { + _ = NSApplication.shared + window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 600, height: 400), + styleMask: styleMask, + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let container = NSView(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) + window.contentView = container + parent = NSView(frame: CGRect(x: 100, y: 80, width: 400, height: 260)) + container.addSubview(parent) + host = NSHostingView(rootView: rootView) + host.sizingOptions = [] + host.frame = CGRect(x: 40, y: 30, width: 100, height: 80) + parent.addSubview(host) + } + } +} +#endif