Supporting Dark Mode: Appearance-Sensitive Preferences

One of the challenges I dealt with in MarsEdit was how to adapt my existing user-facing color preferences to Dark Mode:

Screenshot of MarsEdit's preferences for text foreground and background colors.

The default values and any previously saved user customizations would be pertinent only to Light Mode. I knew I wanted to save separate values for Dark Mode, but I worried about junking up my preferences panel with separate labels and controls for each mode. After playing around with some more complicated ideas, I settled on simplicity itself: I would register and save separate values for each user-facing preference, depending on whether the app is in Dark Mode right now.

When a user switches modes, the visible color preferences in this panel change to the corresponding values for that mode. I reasoned that users are most likely to use these settings to fine-tune the appearance of the app as it appears now, and it would be intuitive to figure out if they wanted to customize the colors separately for each mode.

How did I achieve this? I decided to bottleneck access to these preferences so that as far as any consumer of the preference is concerned, there is only one value. For example, any component of my app that needs to know the “text editing color” consults a single property “bodyTextColor” on my centralized preferences controller. This is what the supporting methods, along with the property accessor, look like:

func color(forKey key: String, defaultColor: NSColor) -> NSColor {
   let defaults = UserDefaults.standard
   guard let color = defaults.rsColor(forKey: key) else {
      return defaultColor
   }
   return color
}

func setColor(color: NSColor, forKey key: String) {
   let defaults = UserDefaults.standard
   defaults.rsSetColor(color, forKey: key)
}

var bodyTextPreferenceKey: String {
   if NSApp.isDarkMode {
      return bodyTextColorForDarkModeKey
   } else {
      return bodyTextColorForLightModeKey
   }
}

@objc var bodyTextColor: NSColor {
   get {
      let key = self.bodyTextPreferenceKey
      return self.color(forKey: key, defaultColor: .textColor)
   }
   set {
      let key = self.bodyTextPreferenceKey
      self.setColor(color: newValue, forKey: key)
      self.sendNotification(.textColorPreferenceChanged)
   }
}

Because the getter and setter always consult internal properties to determine the current underlying defaults key, we always write to and read from the semantic value that the user expects. The only thing the user interface for my Preferences window needs to do is load up the color pane with the value from the getter, and respond to changes by writing with the setter.

You might be wondering what happens to the Preferences interface when the application’s appearance changes. I thought at first I would make the UI controller subscribe to appAppearanceChanged notifications, and reload the contents of the UI to match the current values of pertinent color preferences. It turned out that I didn’t even need to do that. Why?

In my centralized preferences controller, where the bottleneck methods shown above are implemented, each of the pertinent setters sends a notification that the color preference has changed. This allows observers throughout the app to update the color as needed when, for example, the user chooses a new value. If you’re using KVO or another technique for notifying clients of changes, this same approach can be adapted to that style.

After ensuring that all clients are notified when a color preference “changes,” we can reuse that same mechanism to proliferate changes in color preferences that happen to correlate with a change in appearance mode. I simply added an observer to “appAppearanceChanged” within the preference controller, where such notifications are translated into “color preference changed” notifications. The Preferences window observes these, and updates the UI. My text editors notice them, and reconfigure their appearance. Every concerned component of my app is guaranteed to know current colors regardless of how or why they have changed.

It’s worth noting that this is a scenario where using a solution like appearance-sensitive colors might also make sense. Instead of taking responsibility for notifying clients whenever these preferences change, a single color instance that adapts to current appearance would get the job done. This is a situation where even using an asset catalog doesn’t help. Because the colors in this situation are user-generated at run-time, they can’t be stored in a catalog, but a custom subclass of NSColor could achieve the same effect.