macosmacos-big-surnsstatusbarmacos-darkmode

NSAppearance is not updating when toggling dark mode


I have a macOS app that runs only in the macOS status bar. I changed the "Application is agent (UIElement)" property in the Info.plist to "YES":

<key>LSUIElement</key>
<true/>

I have a timer that prints out the appearance's name every 5 seconds like this:

Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
    let appearance = NSAppearance.currentDrawing()
    print(appearance.name)
}

Problem

The name doesn't actually change when I toggle dark/light mode in system settings. It always prints the name of the appearance that was set when the application launched.

Is there a way to listen to system appearance changes?

Goal

My end goal is actually to draw an NSAttributedString to an NSImage, and use that NSImage as the NSStatusItem button's image.

let image: NSImage = // generate image
statusItem.button?.image = image

For the text in the attributed string I use UIColor.labelColor that is supposed to be based on the system appearance. However it seems to not respect the system appearance change.

When I start the application in Dark Mode and then switch to Light Mode:

dark-1 dark-2

When I start the application in Light Mode and then switch to Dark Mode:

light-1 light-2

Side note

The reason why I turn the NSAttributedString into an NSImage and don't use the NSAttributedString directly on the NSStatusItem button's attributedTitle is because it doesn't position correctly in the status bar.


Solution

  • The problem with drawing a NSAttributedString is, that NSAttributedString doesn't know how to render dynamic colors such as NSColor.labelColor. Thus, it doesn't react on appearance changes. You have to use a UI element.

    Solution

    I solved this problem by passing the NSAttributedString to a NSTextField and draw that into an NSImage. Works perfectly fine.

    func updateStatusItemImage() {
    
        // Use UI element: `NSTextField`
        let attributedString: NSAttributedString = ...
        let textField = NSTextField(labelWithAttributedString: attributedString)
        textField.sizeToFit()
    
        // Draw the `NSTextField` into an `NSImage`
        let size = textField.frame.size
        let image = NSImage(size: size)
        image.lockFocus()
        textField.draw(textField.bounds)
        image.unlockFocus()
    
        // Assign the drawn image to the button of the `NSStatusItem`
        statusItem.button?.image = image
    }
    

    React on NSAppearance changes

    In addition, since NSImage doesn't know about NSAppearance either I need to trigger a redraw on appearance changes by observing the effectiveAppearance property of the button of the NSStatusItem:

    observation = statusItem.observe(\.button?.effectiveAppearance, options: []) { [weak self] _, _ in
        // Redraw 
        self?.updateStatusItemImage()
    }