Skip to content

Commit 4b84bd3

Browse files
authored
fix(hosting): restore macOS tap gestures in offset views (#1084)
1 parent bb972ee commit 4b84bd3

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
@property (nonatomic) BOOL ignoreHitTest_openswiftui_safe_wrapper OPENSWIFTUI_SWIFT_NAME(ignoreHitTest);
1818

1919
- (nullable NSResponder *)_nextResponderForEvent:(nullable NSEvent *)event;
20+
- (nonnull id)_observerForChangesInGeometryInWindow:(void (^ _Nonnull)(NSView * _Nonnull view))block;
2021
- (void)_updateLayerGeometryFromView;
2122
- (void)_updateLayerShadowFromView;
2223
- (void)_updateLayerShadowColorFromView;

Sources/OpenSwiftUI/Integration/Hosting/AppKit/View/NSHostingView.swift

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ open class NSHostingView<Content>: NSView, XcodeViewDebugDataProvider where Cont
118118

119119
private var hitTestEventMonitor: Any?
120120

121+
private var geometryChangeRegistrationPhase: HostingViewGeometryRegistrationPhase = .unregistered
122+
123+
private var geometryChangeObserver: Any?
124+
121125
private lazy var eventBindingSource = BindingSource(hostingView: self)
122126

123127
private lazy var eventBridge = AppKitEventBindingBridge(
@@ -487,6 +491,17 @@ open class NSHostingView<Content>: NSView, XcodeViewDebugDataProvider where Cont
487491
}
488492
}
489493

494+
private func geometryInWindowDidChange() {
495+
switch geometryChangeRegistrationPhase {
496+
case .unregistered:
497+
Log.internalError("Received geometry change with no registration for \(self).")
498+
case .registering:
499+
break
500+
case .registered:
501+
invalidateProperties(.transform, mayDeferUpdate: false)
502+
}
503+
}
504+
490505
open override func layout() {
491506
guard canAdvanceTimeAutomatically else {
492507
return
@@ -1074,6 +1089,13 @@ extension NSHostingView: ViewRendererHost {
10741089
viewGraph.setEnvironment(environment)
10751090
}
10761091

1092+
package func updateTransform() {
1093+
if !viewGraph.invalidateTransform(), geometryChangeRegistrationPhase != .unregistered {
1094+
geometryChangeObserver = nil
1095+
geometryChangeRegistrationPhase = .unregistered
1096+
}
1097+
}
1098+
10771099
package func updateSize() {
10781100
viewGraph.setProposedSize(bounds.size)
10791101
}
@@ -1098,6 +1120,8 @@ extension NSHostingView: ViewRendererHost {
10981120
return unsafeBitCast(renderer, to: T.self)
10991121
} else if EventGraphHost.self == T.self {
11001122
return unsafeBitCast(self as any EventGraphHost, to: T.self)
1123+
} else if RootTransformProvider.self == T.self {
1124+
return unsafeBitCast(self as any RootTransformProvider, to: T.self)
11011125
} else {
11021126
return nil
11031127
}
@@ -1137,6 +1161,63 @@ extension NSHostingView: ViewRendererHost {
11371161
}
11381162
}
11391163

1164+
// MARK: - NSHostingView + RootTransformProvider [6.5.4]
1165+
1166+
extension NSHostingView: RootTransformProvider {
1167+
package func rootTransform() -> ViewTransform {
1168+
if geometryChangeRegistrationPhase == .unregistered {
1169+
geometryChangeRegistrationPhase = .registering
1170+
geometryChangeObserver = _observerForChangesInGeometry { view in
1171+
(view as! Self).geometryInWindowDidChange()
1172+
}
1173+
geometryChangeRegistrationPhase = .registered
1174+
}
1175+
guard window != nil else {
1176+
return ViewTransform()
1177+
}
1178+
1179+
var buffer = ViewTransform.UnsafeBuffer()
1180+
let usesRootGeometry = _SemanticFeature_v6.isEnabled
1181+
if !usesRootGeometry {
1182+
buffer.appendCoordinateSpace(id: hostingViewCoordinateSpace)
1183+
}
1184+
var origin = convert(CGPoint.zero, to: nil)
1185+
if _SemanticFeature_v3.isEnabled {
1186+
if _SemanticFeature_v3.isEnabled {
1187+
origin.y = (window?.frame.height ?? 0) - origin.y
1188+
}
1189+
buffer.appendTranslation(CGSize(width: -origin.x, height: -origin.y))
1190+
} else {
1191+
buffer.appendAffineTransform(
1192+
CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: -origin.x, ty: origin.y),
1193+
inverse: false
1194+
)
1195+
}
1196+
if usesRootGeometry {
1197+
buffer.appendScrollGeometry(
1198+
.rootViewTransform(contentOffset: .zero, containerSize: convert(bounds, to: nil).size),
1199+
isClipped: true
1200+
)
1201+
buffer.appendScrollGeometry(
1202+
.rootViewTransform(contentOffset: .zero, containerSize: bounds.size),
1203+
isClipped: clipsToBounds
1204+
)
1205+
buffer.appendCoordinateSpace(id: hostingViewCoordinateSpace)
1206+
} else {
1207+
_ = clipsToBounds
1208+
}
1209+
var transform = ViewTransform()
1210+
transform.append(movingContentsOf: &buffer)
1211+
return transform
1212+
}
1213+
}
1214+
1215+
private enum HostingViewGeometryRegistrationPhase: Hashable {
1216+
case unregistered
1217+
case registering
1218+
case registered
1219+
}
1220+
11401221
@_spi(Private)
11411222
@available(iOS, unavailable)
11421223
@available(tvOS, unavailable)
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//
2+
// NSHostingViewRootTransformTests.swift
3+
// OpenSwiftUITests
4+
5+
#if os(macOS)
6+
import AppKit
7+
import OpenAttributeGraphShims
8+
@_spi(ForOpenSwiftUIOnly)
9+
@testable import OpenSwiftUI
10+
@_spi(ForOpenSwiftUIOnly)
11+
import OpenSwiftUICore
12+
import OpenSwiftUITestsSupport
13+
import Testing
14+
15+
@MainActor
16+
@Suite(.disabled(if: attributeGraphVendor == .oag), .tags(.aigc))
17+
struct NSHostingViewRootTransformTests {
18+
@Test
19+
func detachedHostProvidesIdentityTransform() throws {
20+
let host = NSHostingView(rootView: EmptyView())
21+
let provider = try #require(host.as(RootTransformProvider.self))
22+
let transform = provider.rootTransform()
23+
#expect(transform.convert(.globalToSpace(.local), point: CGPoint(x: 20, y: 15)) == CGPoint(x: 20, y: 15))
24+
}
25+
26+
@Test(arguments: [
27+
(Semantics.v2, CGPoint(x: 160, y: 175)),
28+
(Semantics.v3, CGPoint(x: 160, y: 225)),
29+
(Semantics.v6, CGPoint(x: 160, y: 225)),
30+
])
31+
func windowCoordinatesReachHost(semantics: Semantics, globalPoint: CGPoint) throws {
32+
try semantics.test {
33+
let fixture = Fixture(rootView: EmptyView())
34+
defer { fixture.window.close() }
35+
_ = try #require(fixture.host.as(RootTransformProvider.self))
36+
let transform = fixture.host.updateViewGraph { $0.transform }
37+
#expect(transform.convert(.globalToSpace(.local), point: globalPoint) == CGPoint(x: 20, y: 15))
38+
#expect(transform.convert(.localToSpace(.global), point: CGPoint(x: 20, y: 15)) == globalPoint)
39+
}
40+
}
41+
42+
@Test
43+
func geometryChangesInvalidateRootTransform() throws {
44+
try Semantics.v6.test {
45+
let fixture = Fixture(rootView: EmptyView())
46+
defer { fixture.window.close() }
47+
_ = try #require(fixture.host.as(RootTransformProvider.self))
48+
let initial = fixture.host.updateViewGraph { $0.transform }
49+
#expect(initial.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 140, y: 210))
50+
51+
fixture.host.setFrameOrigin(CGPoint(x: 60, y: 40))
52+
#expect(fixture.host.propertiesNeedingUpdate.contains(.transform))
53+
let moved = fixture.host.updateViewGraph { $0.transform }
54+
#expect(moved.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 160, y: 200))
55+
56+
fixture.parent.setFrameOrigin(CGPoint(x: 150, y: 100))
57+
#expect(fixture.host.propertiesNeedingUpdate.contains(.transform))
58+
let ancestorMoved = fixture.host.updateViewGraph { $0.transform }
59+
#expect(ancestorMoved.convert(.localToSpace(.global), point: .zero) == CGPoint(x: 210, y: 180))
60+
61+
fixture.host.removeFromSuperview()
62+
let detached = fixture.host.updateViewGraph { $0.transform }
63+
#expect(detached.convert(.localToSpace(.global), point: CGPoint(x: 20, y: 15)) == CGPoint(x: 20, y: 15))
64+
}
65+
}
66+
67+
@Test
68+
func windowMouseEventBindsAfterAncestorMovement() throws {
69+
try Semantics.v6.test {
70+
let fixture = Fixture(rootView: Color.red.onTapGesture {})
71+
defer { fixture.window.close() }
72+
let root = try #require(fixture.host.updateViewGraph {
73+
$0.instantiateIfNeeded()
74+
return $0.responderNode
75+
})
76+
let event = MouseEvent(
77+
timestamp: .zero,
78+
button: .primary,
79+
phase: .began,
80+
location: .zero,
81+
globalLocation: CGPoint(x: 160, y: 225),
82+
modifiers: []
83+
)
84+
#expect(root.bindEvent(event) != nil)
85+
86+
fixture.parent.setFrameOrigin(CGPoint(x: 300, y: 100))
87+
let movedRoot = try #require(fixture.host.updateViewGraph { $0.responderNode })
88+
#expect(movedRoot.bindEvent(event) == nil)
89+
var movedEvent = event
90+
movedEvent.globalLocation = CGPoint(x: 360, y: 205)
91+
#expect(movedRoot.bindEvent(movedEvent) != nil)
92+
}
93+
}
94+
95+
@Test
96+
func geometryObserverDoesNotRetainHost() throws {
97+
weak var weakHost: NSHostingView<EmptyView>?
98+
try autoreleasepool {
99+
let host = NSHostingView(rootView: EmptyView())
100+
weakHost = host
101+
let provider = try #require(host.as(RootTransformProvider.self))
102+
_ = provider.rootTransform()
103+
}
104+
#expect(weakHost == nil)
105+
}
106+
107+
@Test(arguments: [NSWindow.StyleMask.borderless, .titled])
108+
func nativeMouseClickRunsTapAction(styleMask: NSWindow.StyleMask) throws {
109+
try Semantics.v6.test {
110+
var tapCount = 0
111+
let fixture = Fixture(rootView: Color.red.onTapGesture { tapCount += 1 }, styleMask: styleMask)
112+
defer { fixture.window.close() }
113+
fixture.host.updateViewGraph { $0.instantiateIfNeeded() }
114+
let recognizer = try #require(fixture.host.gestureRecognizers.first as? AppKitGestureRecognizer)
115+
let location = fixture.host.convert(CGPoint(x: 20, y: 15), to: nil)
116+
let down = try #require(NSEvent.mouseEvent(
117+
with: .leftMouseDown,
118+
location: location,
119+
modifierFlags: [],
120+
timestamp: 1,
121+
windowNumber: fixture.window.windowNumber,
122+
context: nil,
123+
eventNumber: 1,
124+
clickCount: 1,
125+
pressure: 1
126+
))
127+
let up = try #require(NSEvent.mouseEvent(
128+
with: .leftMouseUp,
129+
location: location,
130+
modifierFlags: [],
131+
timestamp: 1.1,
132+
windowNumber: fixture.window.windowNumber,
133+
context: nil,
134+
eventNumber: 2,
135+
clickCount: 1,
136+
pressure: 0
137+
))
138+
Update.perform {
139+
recognizer.mouseDown(with: down)
140+
recognizer.mouseUp(with: up)
141+
}
142+
#expect(tapCount == 1)
143+
}
144+
}
145+
146+
@MainActor
147+
private struct Fixture<Content: View> {
148+
let window: NSWindow
149+
let parent: NSView
150+
let host: NSHostingView<Content>
151+
152+
init(rootView: Content, styleMask: NSWindow.StyleMask = .borderless) {
153+
_ = NSApplication.shared
154+
window = NSWindow(
155+
contentRect: CGRect(x: 0, y: 0, width: 600, height: 400),
156+
styleMask: styleMask,
157+
backing: .buffered,
158+
defer: false
159+
)
160+
window.isReleasedWhenClosed = false
161+
let container = NSView(frame: CGRect(x: 0, y: 0, width: 600, height: 400))
162+
window.contentView = container
163+
parent = NSView(frame: CGRect(x: 100, y: 80, width: 400, height: 260))
164+
container.addSubview(parent)
165+
host = NSHostingView(rootView: rootView)
166+
host.sizingOptions = []
167+
host.frame = CGRect(x: 40, y: 30, width: 100, height: 80)
168+
parent.addSubview(host)
169+
}
170+
}
171+
}
172+
#endif

0 commit comments

Comments
 (0)