Instantly share code, notes, and snippets.
Created
January 22, 2025 21:19
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
Save randomor/79c2e7453587284ae4569fd0dd1fd0c7 to your computer and use it in GitHub Desktop.
Variable Menubar Icon app https://multi.app/blog/pushing-the-limits-nsstatusitem
This file contains hidden or 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
//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