Ardent Swift

How to programatically resolve dynamic Dark and Light Colors

16 Sep 2024

In this article we will take a look at how to programatically resolve dynamic colors from Hex values.

What we want to achieve

Dynamic Color Showcase

If for some reason such as building a Color-configurable Theme Library or UI Library we can’t use the Xcode Asset Catalog, we will either need to create UIColor, NSColor and SwiftUI.Color with RGBA components, or, as often practiced, write an extension on UIColor, NSColor and SwiftUI.Color that allows us to initialize it from a Hex value.

However such a color doesn’t respect Light or Dark Mode and it can become a bit messy to always resolve two color versions in your View and read the UITraitCollection or ColorScheme environment.

But don’t worry, there is a simple solution to that, if we are willing to have some boilerplate!

The approach

In case you need to support both macOS and iOS, let’s start by defining Platform-agnostic typealiases for the UIColor and NSColor. While we can initialize a SwiftUI.Color directly and we don’t have to deal with UIColor and NSColor, we will need this to be able to dynamically resolve Dark and Light mode version of our colors as if they came from the Asset Catalog.

import SwiftUI

#if canImport(AppKit)
    import AppKit

    typealias PlatformColor = NSColor
#elseif canImport(UIKit)
    import UIKit

    typealias PlatformColor = UIColor
#endif

This will make sure that our dynamic color resolution works with both AppKit and UIKit.

Next, we need to add an extension on our new PlatformColor, so that we can initialize it from a Hex value

extension PlatformColor {

    /// Initializes the PlatformColor with Hex String
    /// - RGB (12-bit), ex. "FFF" for pure white
    /// - RGB (24-bit), ex. "FFFFFF" for pure white
    ///
    /// - Parameters:
    ///   - hex: Hex description of the color without the alpha channel
    ///   - alpha: Alpha/opacity to apply to the color, `by default 1.0`. The values have to be between 0.0 and 1.0
    convenience init(hex: String, alpha: Double = 1.0) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)

        let r, g, b: UInt64
        switch hex.count {
        case 3: // RGB (12-bit)
            (r, g, b) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
        case 6: // RGB (24-bit)
            (r, g, b) = (int >> 16, int >> 8 & 0xFF, int & 0xFF)
        default:
            (r, g, b) = (0, 0, 0)
        }

        self.init(
            displayP3Red: Double(r) / 255,
            green: Double(g) / 255,
            blue:  Double(b) / 255,
            alpha: alpha
        )
    }
}

Note that the Hex color has to be a String in either RGB or RRGGBB format.

As of now, we can initialize UIColor and NSColor with a String Hex color value like so:

let myColor = PlatformColor(hex: "#FFFFF")

And to be able to create a SwiftUI.Color with just one initializer and not two, since the one taking NSColor and the one taking UIColor are different, a new initializer will do the trick:

extension Color {

    init(platformColor: PlatformColor) {
        #if canImport(AppKit)
        self = Color(platformColor)
        #else
        self = Color(uiColor: platformColor)
        #endif
    }
}

Now finally to the fun part!

Dynamically resolve Color based on Dark or Light mode

As mentioned above, to save us some headache we will bridge through the PlatformColor by extending it with a static method:

extension PlatformColor {

    static func dynamicColor(light: PlatformColor, dark: PlatformColor) -> PlatformColor {
        #if canImport(AppKit)
        return PlatformColor(name: nil) { $0.isDarkMode ? dark : light }
        #else
        return PlatformColor { $0.userInterfaceStyle == .dark ? dark : light }
        #endif
    }
}

For the UIKit version of PlatformColor we are initializing it with the init(dynamicProvider:) and for the AppKit version of PlatformColor we are using the init(name:dynamicProvider:) initializer. Since the AppKit version contains NSAppearance that has many different appearances, I have also created a convenience computed property that returns true if we have Dark Mode enabled on macOS:

#if canImport(AppKit)
extension NSAppearance {

    var isDarkMode: Bool {
        switch name {
        case .aqua, .vibrantLight, .accessibilityHighContrastVibrantLight, .accessibilityHighContrastAqua:
            return false
        case .darkAqua, .vibrantDark, .accessibilityHighContrastVibrantDark, .accessibilityHighContrastDarkAqua:
            return true
        default:
            return false
        }
    }
}
#endif

Now when we create a PlatformColor with our dynamicColor method, it will become one instance of PlatformColor that will apply the Light or Dark mode version based on the system traits, so we should end up with something like this:

let myPlatformColor = PlatformColor.dynamicColor(
    light: .init(hex: "#FFF"),
    dark: .init(hex: "#000")
)

let myColor = Color(myPlatformColor)

Final steps

And since us programmers are lazy (or at least I am 😳), I want to have an even more convenient way of creating the color with SwiftUI on all platforms. To do so, I added one more initializer (as if it’s not enough already) that will make it more convenient than ever before!

extension Color {

    /// Initializes the Color with Light and Dark Hex String
    /// - RGB (12-bit), ex. "FFF" for pure white
    /// - RGB (24-bit), ex. "FFFFFF" for pure white
    ///
    /// - Parameters:
    ///   - lightHex: Light Mode Hex description of the color
    ///   - darkHex: Dark Mode Hex description of the color, if nil, Light Mode Hex is used
    ///   - opacity: Alpha to apply to the color, `by default 1.0`. The values have to be between 0.0 and 1.0
    init(lightHex: String, darkHex: String, alpha: Double = 1.0) {
        self = Color(
            PlatformColor.dynamicColor(
                light: PlatformColor(hex: lightHex, alpha: alpha),
                dark: PlatformColor(hex: darkHex, alpha: alpha)
            )
        )
    }
}

And now we can store our Colors as easily as this:

enum MyAppColors {

    static let contentPrimary: Color = Color(lightHex: "#000000", darkHex: "#FFFFFF")
}

or in UIKit/AppKit case:

enum MyAppPlatformColors {

    static let contentPrimary: PlatformColor = PlatformColor.dynamicColor(
        light: .init(hex: "#FFF"),
        dark: .init(hex: "#000")
    )
}

Conclusion

With this approach we can replace the Asset Catalog’s colors with programatic Hex values! This enables to have better control over our Colors and better searchability. Also this can come in handy in case you are building some UI SDK that needs to have configurable Theme of colors that support both light and dark mode, which can get a bit tricky with relying only on Asset Catalog.

I wish Xcode also could preview the color as a rectangle next to the Hex value (please,  Apple 🙏), but I guess for now we have to use VSCode 🤡.

As always, I will be happy for any feedback!

Happy coding! 💻

Coming Next 🔜

As of writing this article (Sep 15) I honestly don’t know yet 😅 but don’t worry, I will figure something out 🤞!