In this article we will take a look at how to programatically resolve dynamic colors from Hex values.
What we want to achieve
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 🤞!