Skip to content

Instantly share code, notes, and snippets.

@wtsnz
Last active February 27, 2024 15:53
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save wtsnz/09e5fbbeb9d803e02bd9d3d6c14adcb5 to your computer and use it in GitHub Desktop.
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!)
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
}
}
}
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