Update on 2019-08-06
Apple has added new API to NSColor on macOS Catalina (10.15) that provides the same functionality described on this post. If you’re able to, you should use that.
let color = NSColor(name: "myColor") { appearance in
switch appeareance.bestMatch(from: [.aqua, .darkAqua]) {
case .darkAqua:
return darkAquaColor
case .aqua, default:
return aquaColor
}
}
Update on 2019-11-02
Matt Masciotte wrote a small framework implementing this idea.
Mac OS Mojave was announced at this year’s WWDC, and with it Apple introduced Dark Mode — a new look for Apple’s apps and all third party apps willing to adopt it. And it looks great!
I believe that for any serious app on the Mac, adopting Dark Mode will be a must. And Secrets is no exception.
Apple had two sessions at WWDC just to cover Dark Mode. Succinctly, when it comes to colors, developers need to either:
- use system colors declared on
NSColor
, such aslabelColor
,windowBackgroundColor
,systemRed
etc. These colors are will automatically change depending on wether the app is running in Dark Mode or not. - use asset catalog colors, which now can also vary based on Dark Mode. Colors in asset catalogs were introduced on macOS 10.13.
- use a new API on
NSAppearence
to test for Dark Mode and “manually” change the colors. Like this:if (@available(macOS 10.14, *)) { NSAppearanceName appearanceName = [self.effectiveAppearance bestMatchFromAppearancesWithNames:@[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]]; if ([appearanceName isEqualToString:NSAppearanceNameDarkAqua]) { // use dark color } else { // use light color } }
Overall this doesn’t sound to bad, but… if you want to use a color other than the dynamic colors offered by NSColor and are targeting a macOS older than 10.13, that last option is your only choice. And scattering the little dance above throughout your code is ugly as hell.
Fortunately, we can subclass NSColor
and create our own dynamic colors.
The idea is simple, have a custom NSColor
subclass that will respond as if it’s one color in Dark Mode and another when not. This will hide the logic of which color to use inside that class so the rest of your app does not need to be aware of any of this.
The subclass
Firstly, this subclass needs to know which colors to return in either mode:
@interface DynamicColor : NSColor
- (instancetype)initWithAquaColor:(NSColor *)aquaColor
darkAquaColor:(NSColor *__nullable)darkAquaColor;
@end
To know which color to use at any given moment, we declare an effectiveColor
method that returns the right object:
- (NSColor *)effectiveColor
{
if (@available(macOS 10.14, *)) {
NSAppearance *appearance = [NSAppearance currentAppearance] ?: [NSApp effectiveAppearance];
NSAppearanceName appearanceName = [appearance bestMatchFromAppearancesWithNames:@[NSAppearanceNameAqua, NSAppearanceNameDarkAqua]];
if (self.darkAquaColor != nil && [appearanceName isEqualToString:NSAppearanceNameDarkAqua]) {
return self.darkAquaColor;
}
}
return self.aquaColor;
}
Finally, we just need to forward all of NSColor
methods to the color instance returned by this method… which can be quite cumbersome. Fortunately, I was using Objective-C1 and a simple macro can give us a hand.
#define FORWARD( PROP, TYPE ) - (TYPE)PROP { return [self.effectiveColor PROP]; }
// which looks like this when used
FORWARD(colorSpace, NSColorSpace *)
I’ll spare you the rest of the forwarding methods, but you can get the entire class on this gist.
And that’s it!
Since Secrets already declared a bunch of colors in a separate style class, I was able to get a lot done simply by returning a DynamicColor
instead of plain colors.
Note that the same caveats regarding dynamic colors declared by NSColor
also apply here. If you’re extracting the CGColor
to update a layer’s backgroundColor
for example, you need to do it in one of the methods specified here. This is necessary because the extracted CGColor
is not dynamic, and you also need to guarantee [NSAppearance currentAppearance]
is returning the correct value (which it will inside those methods).
-
Swift is cool… but not this cool ;) ↩︎