Last active
September 5, 2024 01:17
-
-
Save wtsnz/09e5fbbeb9d803e02bd9d3d6c14adcb5 to your computer and use it in GitHub Desktop.
Playing around with hosting SwiftUI Views in an NSWindow from inside a View π (also works for the NSStatusBar item!)
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
import SwiftUI | |
struct ContentView: View { | |
@State var now = Date() | |
@State var showWindow = false | |
@State var text: String = "" | |
let timer = Timer.publish(every: 1, on: .current, in: .common).autoconnect() | |
var body: some View { | |
ZStack { | |
// Uncomment for a status item that does nothing other than show the title text. | |
// StatusItemView(title: .constant("\(now.timeIntervalSince1970)")) | |
// Attempt 2: Show a view in a popover from the status item | |
StatusItemViewWithPopover(title: .constant("\(now.timeIntervalSince1970)")) { | |
VStack(alignment: .leading, spacing: 20) { | |
Text("Here's a view inside SwiftUI") | |
.font(.title) | |
Text("\(self.now.timeIntervalSince1970)") | |
} | |
.padding(100) | |
.frame(minWidth: 400, idealWidth: 400, minHeight: 400, idealHeight: 400) | |
} | |
WindowView(isVisible: $showWindow) { | |
Group { | |
Text("\(self.now.timeIntervalSince1970)") | |
.frame(width: 100) | |
Button( | |
action: { | |
self.showWindow.toggle() | |
}, | |
label: { | |
Text("Close") | |
} | |
) | |
} | |
.padding(40) | |
} | |
HStack { | |
Text("\(self.now.timeIntervalSince1970)") | |
.onReceive(timer) { _ in | |
self.now = Date() | |
} | |
Button( | |
action: { | |
self.showWindow.toggle() | |
}, | |
label: { | |
Text("Tap") | |
} | |
) | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
} | |
} | |
} | |
struct WindowView<WindowContent>: NSViewControllerRepresentable where WindowContent: View { | |
typealias NSViewControllerType = NSHostingController<AnyView> | |
@Binding var isVisible: Bool | |
var windowContent: () -> WindowContent | |
init(isVisible: Binding<Bool>, @ViewBuilder windowContent: @escaping () -> WindowContent) { | |
self._isVisible = isVisible | |
self.windowContent = windowContent | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
func makeNSViewController(context: NSViewControllerRepresentableContext<WindowView>) -> NSHostingController<AnyView> { | |
// Return an empty view as we don't want to render anything where this WindowView is hosted. | |
return NSHostingController(rootView: AnyView(EmptyView())) | |
} | |
func updateNSViewController(_ nsViewController: WindowView.NSViewControllerType, context: NSViewControllerRepresentableContext<WindowView>) { | |
context.coordinator.hostingViewController.rootView = AnyView(self.windowContent()) | |
// Ensure that the visiblity has changed. | |
if isVisible != context.coordinator.window.isVisible { | |
if isVisible { | |
context.coordinator.window.makeKeyAndOrderFront(nil) | |
} else { | |
context.coordinator.window.orderOut(nil) | |
} | |
} | |
} | |
static func dismantleNSViewController(_ nsViewController: NSHostingController<AnyView>, coordinator: WindowView<WindowContent>.Coordinator) { | |
print(#function) | |
} | |
class Coordinator: NSObject { | |
var parent: WindowView | |
let window = NSWindow( | |
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300), | |
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], | |
backing: .buffered, | |
defer: true | |
) | |
let hostingViewController = NSHostingController(rootView: AnyView(EmptyView())) | |
init(_ windowManager: WindowView) { | |
self.parent = windowManager | |
window.contentViewController = hostingViewController | |
} | |
} | |
} | |
struct StatusItemView: NSViewControllerRepresentable { | |
typealias NSViewControllerType = NSHostingController<AnyView> | |
@Binding var title: String | |
init(title: Binding<String>) { | |
self._title = title | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
func makeNSViewController(context: NSViewControllerRepresentableContext<StatusItemView>) -> NSHostingController<AnyView> { | |
// Return an empty view as we don't want to render anything where this WindowView is hosted. | |
return NSHostingController(rootView: AnyView(EmptyView())) | |
} | |
func updateNSViewController(_ nsViewController: StatusItemView.NSViewControllerType, context: NSViewControllerRepresentableContext<StatusItemView>) { | |
context.coordinator.statusItem.button?.title = title | |
// Could also add support for all of these! | |
// statusItem.button?.toolTip = "Focus" | |
// statusItem.image = NSImage(named: "Account") | |
// statusItem.alternateImage = NSImage(named: "StatusHighlighted") | |
// statusItem.action = #selector(onPress(sender:)) | |
// statusItem.target = self | |
// statusItem.length = NSStatusItem.variableLength | |
// statusItem.length = 80 // For now, a fixed length fixes an issue | |
} | |
class Coordinator: NSObject { | |
var parent: StatusItemView | |
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | |
init(_ statusItemView: StatusItemView) { | |
self.parent = statusItemView | |
} | |
} | |
} | |
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
struct StatusItemViewWithPopover<PopoverContent>: NSViewControllerRepresentable where PopoverContent: View { | |
typealias NSViewControllerType = NSHostingController<AnyView> | |
@Binding var title: String | |
var popoverContent: () -> PopoverContent | |
init(title: Binding<String>, @ViewBuilder popoverContent: @escaping () -> PopoverContent) { | |
self._title = title | |
self.popoverContent = popoverContent | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(self) | |
} | |
func makeNSViewController(context: NSViewControllerRepresentableContext<StatusItemViewWithPopover>) -> NSHostingController<AnyView> { | |
// Return an empty view as we don't want to render anything where this WindowView is hosted. | |
return NSHostingController(rootView: AnyView(EmptyView())) | |
} | |
func updateNSViewController(_ nsViewController: StatusItemViewWithPopover.NSViewControllerType, context: NSViewControllerRepresentableContext<StatusItemViewWithPopover>) { | |
context.coordinator.statusItem.button?.title = title | |
context.coordinator.hostingViewController.rootView = AnyView(popoverContent()) | |
// Could also add support for all of these! | |
// statusItem.button?.toolTip = "Focus" | |
// statusItem.image = NSImage(named: "Account") | |
// statusItem.alternateImage = NSImage(named: "StatusHighlighted") | |
// statusItem.action = #selector(onPress(sender:)) | |
// statusItem.target = self | |
// statusItem.length = NSStatusItem.variableLength | |
// statusItem.length = 80 // For now, a fixed length fixes an issue | |
} | |
class Coordinator: NSObject { | |
var parent: StatusItemViewWithPopover | |
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | |
let popover: NSPopover | |
var popoverMonitor: AnyObject? | |
let hostingViewController = NSHostingController(rootView: AnyView(EmptyView())) | |
init(_ statusItemView: StatusItemViewWithPopover) { | |
self.parent = statusItemView | |
popover = NSPopover() | |
super.init() | |
popover.animates = false | |
popover.contentViewController = hostingViewController | |
statusItem.button?.action = #selector(onPress(sender:)) | |
statusItem.button?.target = self | |
} | |
@objc func onPress(sender: AnyObject) { | |
if popover.isShown == false { | |
openPopover() | |
} | |
else { | |
closePopover() | |
} | |
} | |
func openPopover() { | |
if let statusView = statusItem.button { | |
popover.animates = false | |
popover.show(relativeTo: NSZeroRect, of: statusView, preferredEdge: NSRectEdge.minY) | |
popoverMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown, handler: { (event: NSEvent!) -> Void in | |
self.closePopover() | |
}) as AnyObject | |
} | |
} | |
func closePopover() { | |
popover.close() | |
if let monitor: AnyObject = popoverMonitor { | |
NSEvent.removeMonitor(monitor) | |
popoverMonitor = nil | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment