Supporting Dark Mode: Adapting Colors

Given the dramatic visual differences between appearances, virtually every color in your app will need to be varied at drawing time to work well with the current appearance.

Use Semantic Colors

The good news is all of Apple’s built-in, semantically named colors are automatically adapted to draw with a suitable color for the current appearance. For example, if you call “NSColor.textColor.set()” in light mode, and draw a string, it will render dark text, whereas in dark mode it will render light text.

Seek out areas in your app where you use hard-coded colors and determine whether a system-provided semantic color would work just as well as, or better than, the hard-coded value. Among the most common fixes in this area will be locating examples where NSColor.white is used to clear a background before drawing text, when NSColor.textBackgroundColor will do the job better, and automatically adapt itself to Dark Mode.

Vary the Color at Drawing Time

In scenarios where a truly custom color is required, you have a few options. If the color is hard-coded in a drawing method, you may be able to get away with simply querying NSAppearance.current from the point where the drawing occurs. For example, a custom NSView subclass that simply fills itself with a hard-coded color:

override func draw(_ dirtyRect: NSRect) {
   super.draw(dirtyRect)

   // Custom yellow for light mode, custom purple for dark...

   let lightColor = NSColor(red:1, green:1, blue:0.8, alpha:1)
   let darkColor = NSColor(red:0.5, green:0.3, blue:0.6, alpha:1)

   if NSAppearance.current.isDarkMode {
      darkColor.setFill()
   } else {
      lightColor.setFill()
   }

   dirtyRect.fill()
}

Use Asset Catalogs

Special cases like above are fine when you draw your own graphics, but what about views that the system frameworks draw for you? For example, because NSBox supports drawing a custom background color, you’re unlikely to implement a custom view exactly like the one above. Its functionality is redundant with what NSBox provides “for free.” But how can you ensure that NSBox will always draw in the right color for the current appearance? It only supports one “fillColor” property.

A naive approach would involve paying attention to changes in appearance, and re-setting the fill color on NSBox every time. This would get the job done, but is more complicated and error-prone than simply setting a named system color like “textBackgroundColor” and letting it handle all the details of accommodating the current appearance.

Luckily, Apple provides a mechanism for adding custom named colors that behave the same way as standard colors do. Colors that are included in an asset catalog can be identified by name, and can be varied in the catalog to represent distinct colors depending on the appearance they are used in. In short, by defining all your hard-coded colors in asset catalogs, you can treat custom named colors like “funkyBackgroundColor” the same as standard colors like “textBackgroundColor.”

Ah, but there’s a catch. To take advantage of these fancy new asset catalog features, you need to not only build your app with Xcode 10, but you need to build it on a machine that is running macOS 10.14 or greater. Evidently the ability to generate suitable asset catalogs depends on some system functionality that Xcode 10 can’t (or at least doesn’t) replicate when running on 10.13. If you try to use these features in Xcode 10 on 10.13, you’ll run into warnings like:

warning: Named colors referencing system colors must be compiled on 10.14 to maintain dynamic behavior at runtime. Using fixed values for color 'textColor'

and

Varying images and colors by appearance requires building on macOS 10.14 or later.

Because of the utility of asset colors for both colors and images, I strongly recommend updating to Xcode 10 and building on 10.14.

Appearance-Sensitive Colors

If for some reason you can’t yet build your app with Xcode 10 on macOS 10.14, you might eke out a stopgap solution similar to mine. Because I started adopting Dark Mode shortly after WWDC 2018, before Xcode 10 or macOS 10.14 were final, I felt I needed something to simulate the behavior of catalog colors, but implemented independently of them.

I developed a class for my apps called RSAppearanceSensitiveColor, a subclass of NSColor that wraps multiple colors. If you decide to do something similar, be sure to take a look at the subclassing notes in NSColor’s documentation to ensure you override all the required methods. The most functionally important methods to override are “set”, “setFill”, and “setStroke”, because these methods are called at runtime by code that is responsible for drawing. If, for example, you implement a subclass that stores “lightModeColor” and “darkModeColor” as variants, you could add an internal helper method to resolve it:

var currentColor: NSColor {
   if NSAppearance.current.isDarkMode {
      return self.darkModeColor
   }
   else {
      return self.lightModeColor
   }
}

This takes advantage of the NSAppearance.isDarkMode property I described in Checking Appearances, making it easy to specialize based on the current appearance. Remember: that’s the one that is currently being used to perform drawing, etc. When a client that is configured with one of our colors, NSBox for example, calls our set() before drawing its background:

override public func set() {
   self.currentColor.set()
}

The actual drawing color will be appropriate for the current appearance, just as with Apple’s semantic and catalog-backed colors.