Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/COpenSwiftUI/Shims/AppKit/NSView_Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ open class NSHostingView<Content>: 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(
Expand Down Expand Up @@ -487,6 +491,17 @@ open class NSHostingView<Content>: 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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EmptyView>?
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<Content: View> {
let window: NSWindow
let parent: NSView
let host: NSHostingView<Content>

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
Loading