Skip to content

Instantly share code, notes, and snippets.

@stephancasas
Last active April 14, 2024 16:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stephancasas/93558aeefce260d6ba72e13890f036e3 to your computer and use it in GitHub Desktop.
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.
//
// 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))
}
}
@stephancasas
Copy link
Author

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 of HSplitView. This is critically important because reliably accessing a SwiftUI-managed target NSView requires traversing the NSView hierarchy from an established point.

Because HSplitView maintains order, the target view will be forced to draw in the NSView 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 SwiftUI Button to an AppKit method — like collapsing a spring-loaded sidebar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment