Skip to content

Instantly share code, notes, and snippets.

@randomor
Created January 22, 2025 21:19
Show Gist options
  • Save randomor/79c2e7453587284ae4569fd0dd1fd0c7 to your computer and use it in GitHub Desktop.
Save randomor/79c2e7453587284ae4569fd0dd1fd0c7 to your computer and use it in GitHub Desktop.
//Original article: https://multi.app/blog/pushing-the-limits-nsstatusitem
import SwiftUI
import Combine
@main
struct MenuBarApp: App {
// Tie our custom AppDelegate into SwiftUI's lifecycle
@NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
var body: some Scene {
// We won't actually show a main window here, but SwiftUI requires a scene.
// Using Settings for demonstration; you could also use .window if needed.
Settings {
EmptyView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
private let statusItemManager = StatusItemManager()
func applicationDidFinishLaunching(_ notification: Notification) {
// Create the status item once the app has finished launching
statusItemManager.createStatusItem()
}
}
// MARK: - StatusItemManager
final class StatusItemManager: ObservableObject {
private var hostingView: NSHostingView<StatusItem>?
private var statusItem: NSStatusItem?
private var sizePassthrough = PassthroughSubject<CGSize, Never>()
private var sizeCancellable: AnyCancellable?
func createStatusItem() {
// Use a variable-length status item
let statusItem: NSStatusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
// Create our SwiftUI view and wrap it in an NSHostingView
let hostingView = NSHostingView(rootView: StatusItem(sizePassthrough: sizePassthrough))
hostingView.frame = NSRect(x: 0, y: 0, width: 80, height: 24)
// Place the hosting view inside the status item's button
if let button = statusItem.button {
button.frame = hostingView.frame
button.addSubview(hostingView)
}
self.statusItem = statusItem
self.hostingView = hostingView
// Listen for size changes from the SwiftUI view
sizeCancellable = sizePassthrough.sink { [weak self] size in
let frame = NSRect(origin: .zero, size: .init(width: size.width, height: 24))
self?.hostingView?.frame = frame
self?.statusItem?.button?.frame = frame
}
}
}
// MARK: - A PreferenceKey to capture SwiftUI’s size
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue()
}
}
// MARK: - The SwiftUI content shown in the status bar
struct StatusItem: View {
var sizePassthrough: PassthroughSubject<CGSize, Never>
@State private var showWave: Bool = false
@State private var menuShown: Bool = false
var body: some View {
HStack(spacing: 0) {
// First target
Button {
showWave.toggle()
} label: {
Text("Hello, world!")
.foregroundColor(.white)
}
.buttonStyle(StatusItemButtonStyle())
// Second target, conditionally shown
if showWave {
Button {
menuShown.toggle()
} label: {
Text("👋")
}
.buttonStyle(StatusItemButtonStyle())
.popover(isPresented: $menuShown) {
Image(systemName: "hand.wave")
.resizable()
.frame(width: 100, height: 100)
.padding()
}
}
}
// Help SwiftUI compute the correct width
.fixedSize()
// Capture size changes via GeometryReader
.overlay(
GeometryReader { proxy in
Color.clear.preference(key: SizePreferenceKey.self, value: proxy.size)
}
)
// Send size changes through the PassthroughSubject
.onPreferenceChange(SizePreferenceKey.self) { newSize in
sizePassthrough.send(newSize)
}
// Provide a background color so white text is visible on a dark menu bar
.padding(.horizontal, 8)
.background(Color.blue)
}
}
// MARK: - Custom Button Style
struct StatusItemButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.padding(2)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(Color.white.opacity(configuration.isPressed ? 0.3 : 0.0))
)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment