Last active
April 14, 2024 16:19
-
-
Save stephancasas/93558aeefce260d6ba72e13890f036e3 to your computer and use it in GitHub Desktop.
A SwiftUI view that can reliably access outer or inner instances of SwiftUI-managed NSView objects.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// NSViewReader.swift | |
// | |
// Created by Stephan Casas on 4/11/24. | |
// | |
import SwiftUI; | |
import AppKit; | |
import Combine; | |
// MARK: - NSViewReader View | |
/// A `View` which provides pre-first draw relative `NSView` access to an inner or outer `NSView` instance of the specified type. | |
struct NSViewReader<Content: View, NSViewProxy>: View { | |
private var content: () -> Content; | |
private let proxyCallback: NSViewProxySubscribingCallback; | |
/// A type which describes both the `NSView` type to proxy and its hierarchical position relative to the content of `NSViewReader`. | |
enum NSViewRelativeProxyType { | |
/// The proxied `NSView` is within the `NSViewReader`'s content. | |
case inner(NSViewProxy.Type); | |
/// The proxied `NSView` is outside of the `NSViewReader`'s content. | |
case outer(NSViewProxy.Type); | |
} | |
private let relativeType: NSViewRelativeProxyType; | |
/// Create a new `NSViewReader` for the given relative `NSView` type and the given `NSView`-receiving/subscription store-receiving closure. | |
/// - Parameters: | |
/// - nsViewType: The relative `NSView` type to resolve. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type and a subscriptions store for binding SwiftUI-managed event contexts to AppKit-managed objects. | |
/// - content: The content view of this `View`. | |
init( | |
_ nsViewType: NSViewRelativeProxyType, | |
using callback: @escaping NSViewProxySubscribingCallback, | |
@ViewBuilder content: @escaping () -> Content | |
) { | |
self.relativeType = nsViewType; | |
self.content = content; | |
self.proxyCallback = callback; | |
} | |
/// Create a new `NSViewReader` for the given relative `NSView` type and the given `NSView`-receiving closure. | |
/// - Parameters: | |
/// - nsViewType: The relative `NSView` type to resolve. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type. | |
/// - content: The content view of this `View`. | |
init( | |
_ nsViewType: NSViewRelativeProxyType, | |
using callback: @escaping NSViewProxyCallback, | |
@ViewBuilder content: @escaping () -> Content | |
) { | |
self.relativeType = nsViewType; | |
self.content = content; | |
self.proxyCallback = { view, _ in callback(view) }; | |
} | |
var body: some View { | |
HSplitView(content: { | |
// This view is collapsed pre-first draw. | |
NSViewReaderSwiftUIContextView(self.proxyCallback, self.relativeType) | |
self.content() | |
}) | |
} | |
typealias NSViewProxyCallback = (_ view: NSViewProxy) -> Void; | |
typealias NSViewProxySubscribingCallback = (_ view: NSViewProxy, _ subscriptions: inout Set<AnyCancellable>) -> Void | |
struct NSViewReaderSwiftUIContextView: NSViewRepresentable { | |
private let proxyCallback: NSViewProxySubscribingCallback; | |
private let relativeType: NSViewRelativeProxyType; | |
init(_ proxyCallback: @escaping NSViewProxySubscribingCallback, _ relativeType: NSViewRelativeProxyType) { | |
self.proxyCallback = proxyCallback; | |
self.relativeType = relativeType; | |
} | |
func makeNSView(context: Context) -> NSViewReaderAppKitContextView { | |
let view = NSViewReaderAppKitContextView(frame: .zero); | |
view.proxyCallback = self.proxyCallback; | |
view.relativeType = self.relativeType; | |
return view; | |
} | |
func updateNSView(_ nsView: NSViewType, context: Context) {} | |
class NSViewReaderAppKitContextView: NSView { | |
/// The user-provided `NSView`-receiving callback. | |
var proxyCallback: NSViewProxySubscribingCallback = {_, _ in} | |
/// A subscriptions store which can be accessed from the user-provided callback. | |
/// | |
/// This can be used to bind SwiftUI-managed events to AppKit-managed views. | |
var subscriptions = Set<AnyCancellable>(); | |
/// The `NSView` type to resolve and its hierarchical position relative to this view. | |
var relativeType: NSViewRelativeProxyType? = nil; | |
/// Perform utility and user-provided operations before the first draw. | |
override func viewWillDraw() { | |
super.viewWillDraw(); | |
// Collapse this view in the managed `HSplitView` before the first draw. | |
self.collapse(); | |
// Resolve and provide the targeted NSView type. | |
switch self.relativeType { | |
case .inner: | |
return self.performCallbackWithInnerMatch(); | |
case .outer: | |
return self.performCallbackWithOuterMatch(); | |
case .none: | |
return; | |
} | |
} | |
/// Traverse upward until the targeted `NSView` type is found, then perform the assigned callback. | |
private func performCallbackWithOuterMatch() { | |
guard let splitView = self.ownManagedSplitView else { | |
return; | |
} | |
var target: NSView? = splitView.superview; | |
while target != nil { | |
if let target = target as? NSViewProxy { | |
self.proxyCallback(target, &self.subscriptions); | |
break; | |
} | |
target = target?.superview; | |
} | |
} | |
/// Traverse inward until the targeted `NSView` type is found, then perform the assigned callback. | |
private func performCallbackWithInnerMatch() { | |
guard let wrapper = self.wrapper else { return } | |
var locator: (NSView) -> NSViewProxy? = { _ in nil }; | |
locator = { view in | |
guard let target = view as? NSViewProxy else { | |
return view.subviews.compactMap({ | |
locator($0) | |
}).first; | |
} | |
return target; | |
} | |
guard | |
let wrapperSibling = wrapper.superview?.subviews.first( | |
where: { $0 != wrapper && $0.className == $0.className }), | |
let target = locator(wrapperSibling) | |
else { | |
return; | |
} | |
self.proxyCallback(target, &self.subscriptions); | |
} | |
private var _ownManagedSplitView: NSSplitView? = nil; | |
/// The `SwiftUI.HSplitView`-managed `NSSplitView` which contains this view. | |
private var ownManagedSplitView: NSSplitView? { | |
if let cached = self._ownManagedSplitView { | |
return cached; | |
} | |
var target: NSView? = self.wrapper; | |
while target != nil { | |
if let target = target as? NSSplitView { | |
self._ownManagedSplitView = target; | |
return target; | |
} | |
target = target?.superview; | |
} | |
return nil; | |
} | |
private var _wrapper: NSView? = nil; | |
/// The private `_NSSplitViewItemViewWrapper` which contains this view. | |
private var wrapper: NSView? { | |
if let cached = self._wrapper { | |
return cached; | |
} | |
var target: NSView = self; | |
while true { | |
if target.className.elementsEqual("_NSSplitViewItemViewWrapper") { | |
break; | |
} | |
guard let superview = target.superview else { | |
break; | |
} | |
target = superview; | |
} | |
self._wrapper = target == self ? nil : target; | |
return self._wrapper; | |
} | |
/// Collapse this utility view within the `SwiftUI.HSplitView` owned by this view's `NSViewReader`. | |
private func collapse() { | |
guard | |
let wrapper = self.wrapper, | |
let splitViewItem = wrapper.perform( | |
NSSelectorFromString("splitViewItem") | |
).takeUnretainedValue() as? NSSplitViewItem | |
else { | |
return; | |
} | |
splitViewItem.isCollapsed = true; | |
} | |
} | |
} | |
} | |
// MARK: - ViewModifier | |
/// A `ViewModifier` which provides pre-first draw relative `NSView` access to an inner or outer `NSView` instance of the specified type, by wrapping the `View` on which it is applied inside of `NSViewReader`. | |
struct NSViewReaderModifier<NSViewProxy>: ViewModifier { | |
typealias NSViewReaderType = NSViewReader<Content, NSViewProxy>; | |
typealias NSViewRelativeProxyType = NSViewReaderType.NSViewRelativeProxyType; | |
private let nsViewType: NSViewRelativeProxyType; | |
private let proxyCallback: NSViewReaderType.NSViewProxySubscribingCallback; | |
/// Create a new `NSViewReaderModifier` for the given relative `NSView` type and the given `NSView`-receiving/subscription store-receiving closure. | |
/// - Parameters: | |
/// - nsViewType: The relative `NSView` type to resolve. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type and a subscriptions store for binding SwiftUI-managed event contexts to AppKit-managed objects. | |
init(_ nsViewType: NSViewRelativeProxyType, using callback: @escaping NSViewReaderType.NSViewProxySubscribingCallback) { | |
self.nsViewType = nsViewType; | |
self.proxyCallback = callback; | |
} | |
/// Create a new `NSViewReaderModifier` for the given relative `NSView` type and the given `NSView`-receiving closure. | |
/// - Parameters: | |
/// - nsViewType: The relative `NSView` type to resolve. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type. | |
init(_ nsViewType: NSViewRelativeProxyType, using callback: @escaping NSViewReaderType.NSViewProxyCallback) { | |
self.nsViewType = nsViewType; | |
self.proxyCallback = { view, _ in callback(view) }; | |
} | |
func body(content: Content) -> some View { | |
NSViewReader(self.nsViewType, using: self.proxyCallback, content: { | |
content | |
}) | |
} | |
} | |
// MARK: - View + Use NSView | |
extension View { | |
/// Acquire pre-first draw access to the closest `NSView` instance of the given type, relative to this `View`. | |
/// - Parameters: | |
/// - nsViewType: The type of `NSView` expected by `callback`. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type and a subscriptions store for binding SwiftUI-managed event contexts to AppKit-managed objects. | |
func use<NSViewType>( | |
_ nsViewType: NSViewReaderModifier<NSViewType>.NSViewRelativeProxyType, | |
with callback: @escaping NSViewReaderModifier<NSViewType>.NSViewReaderType.NSViewProxySubscribingCallback | |
) -> some View { | |
self.modifier(NSViewReaderModifier(nsViewType, using: callback)) | |
} | |
/// Acquire pre-first draw access to the closest `NSView` instance of the given type, relative to this `View`. | |
/// - Parameters: | |
/// - nsViewType: The type of `NSView` expected by `callback`. | |
/// - callback: A closure which receives an instance of the specified relative `NSView` type. | |
func use<NSViewType>( | |
_ nsViewType: NSViewReaderModifier<NSViewType>.NSViewRelativeProxyType, | |
with callback: @escaping NSViewReaderModifier<NSViewType>.NSViewReaderType.NSViewProxyCallback | |
) -> some View { | |
self.modifier(NSViewReaderModifier(nsViewType, using: callback)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is intentionally based on SwiftUI's
HSplitView
as a base component.Prior to first draw, the utility view (
NSViewReaderContextView
) is immediately collapsed so that only the content view is visible. However, even though the utility view is collapsed, it is not deallocated and it maintains its order as a subview ofHSplitView
. This is critically important because reliably accessing a SwiftUI-managed targetNSView
requires traversing theNSView
hierarchy from an established point.Because
HSplitView
maintains order, the target view will be forced to draw in theNSView
which is the next sibling of the utility view,NSViewReaderContextView
. Using this as a the entry point for hierarchy traversal, locating the target view by type can return reliable output on every draw cycle.NSViewReaderContextView
provides a subscriptions store (Set<AnyCancellable>
) for passing events from SwiftUI contexts into the callback-provided AppKit context. This can be extremely useful if you wish to bind a SwiftUIButton
to an AppKit method — like collapsing a spring-loaded sidebar.