Replacement for NSAppearance.performAs­Current­Drawing­Appearance on macOS 10.14 and 10.15 to Fetch the Correct NSColor.cgColor

Today, I had trouble getting NSColor to work with colors from Asset catalogues when asking for its .cgColor.

Since NSColor is appearance-aware, i.e. it switches light and dark mode appropriately when used directly in your views, I wondered why asking for .cgColor always returned the initial value. Say we start the app in light mode, then this is always going to be the light mode color, never dark mode. Yes, not even if you initialize the color anew using NSColor(named: ...).cgColor.

I knew that storing the CGColor won’t dynamically update the result, but not even the computed .cgColor property? – That implies it’s not NSColor’s job to be appearance-aware. It’s someone else’s job.

That didn’t let me leave work in peace, so I spent my evening fiddling with this for an hour or two with a test app. I found that NSAppearance.performAs­CurrentDrawing­Appearance { ... } does the job. But that’s only for macOS 11+, so it won’t do.

Searching the web for performAsCurrentDrawingAppearance produces very little results. Nobody else wanting this on macOS 10.14 and 10.15? The Mozilla bug tracker has a ticket that contains a fix that sets NSAppearance.current before performing some drawing stuff – that was the only hint I found in that web search that made things click. You can and should set NSAppearance.current just like you work with fill colors and NSGraphicsContext stuff. It’s some global state, but it’s also supposed to be changed by you all the time.

In hindsight, Daniel Jalkut told as much in his 2018 article on Dark Mode:

NSAppearance.current or +[NSAppearance currentAppearance] is a class property of NSAppearance that describes the appearance that is currently in effect for the running thread. Practically speaking you can think of this property as an ephemeral drawing variable akin to the current fill color or stroke color. Its value impacts the manner in which drawing that is happening right now should be handled. Don’t confuse it with high-level user-facing options about which mode is set for the application as a whole. [Bold emphasis mine.]

I just didn’t connect the dots properly.

And with these hints, I wrote a block-based helper that would replace performAs­CurrentDrawing­Appearance for me:

extension NSAppearanceCustomization {
    @discardableResult
    public func performWithEffectiveAppearanceAsDrawingAppearance<T>(
            _ block: () -> T) -> T {
        // Similar to `NSAppearance.performAsCurrentDrawingAppearance`, but
        // works below macOS 11 and assigns to `result` properly
        // (capturing `result` inside a block doesn't work the way we need).
        let result: T
        let old = NSAppearance.current
        NSAppearance.current = self.effectiveAppearance
        result = block()
        NSAppearance.current = old
        return result
    }
}

extension NSColor {
    /// Uses the `NSApplication.effectiveAppearance`.
    /// If you need per-view accurate appearance, prefer this instead:
    ///
    ///     let cgColor = aView.performWithEffectiveAppearanceAsDrawingAppearance { aColor.cgColor }
    var effectiveCGColor: CGColor { NSApp.performWithEffectiveAppearanceAsDrawingAppearance { self.cgColor } }
}

This will now get the effective CGColor of any NSColor, based on the NSApp.effectiveAppearance.

For finer-grained control, create a context by using aView.performWith­EffectiveAppearance­AsDrawingAppearance { ... } instead. That allows each view to opt out of automatic appearance changes and set its context appropriately.

So today I learned that NSColor from Asset catalogues do not magically auto-resolve the required appearance; NSColor relies on NSAppearance.current to be set properly. That’s done by views (in draw(_:), for example, but not in viewDidChangeEffectiveAppearance() – I checked).