Articles, podcasts and news about Swift development, by John Sundell.

Defining dynamic colors in Swift

Published on 15 Aug 2021
Discover page available: SwiftUI

For the most part, it’s fair to say that modern iOS and Mac apps are expected to gracefully adapt to whether the user’s device is running in light or dark mode, which often requires us to use more dynamic colors within the UIs that we build.

While Apple does make it fairly straightforward to declare such dynamic colors using Xcode’s asset catalog system, sometimes we might want to define our colors inline within our Swift code instead. So let’s take a look at a few ways to do just that, using either SwiftUI or UIKit.

RevenueCat

RevenueCat: Easily build and manage iOS and Android in-app purchases. With just a few lines of code RevenueCat provides IAP infrastructure, customer analytics, data integrations, and gives you time back from dealing with edge cases and updates across all platforms.

Using system colors

Perhaps the simplest way to ensure that our colors adapt to various user preferences is to use the pre-defined colors that ship as part of both UIKit and SwiftUI as much as possible. All of SwiftUI’s built-in colors are adaptive by default, and the same thing is true for all UIColor APIs that are prefixed with system:

// SwiftUI
label.foregroundColor(.orange)

// UIKit
label.textColor = .systemOrange

Although the above labels will always have an orange text color, the exact shade of orange that’s used will vary both depending on whether the user’s device is using dark or light mode, and whether certain accessibility settings have been enabled (such as Increase Contrast).

Both SwiftUI and UIKit also offer a suite of colors that are a bit more abstract, which will then resolve to specific, context-appropriate colors at runtime. For example, when using SwiftUI, the primary and secondary colors are often particularly useful when working with text, as they’ll make our text colors match the ones used throughout the system:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(.primary)
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

💡 Tip: Use the PREVIEW button to see what the above code sample looks like when rendered using both light and dark mode.

UIKit contains an even more comprehensive set of contextual colors that are a bit more tied to the default colors used for certain system components. So the equivalent of SwiftUI’s primary and secondary colors are referred to as label and secondaryLabel when working with UIColor:

label.textColor = .label
detailLabel.textColor = .secondaryLabel
view.backgroundColor = .systemBackground

For a complete list of colors, check out Apple’s “UI Element Colors” documentation page.

Custom colors

While the system-provided colors are definitely a great starting point, we’ll likely also want to use a few completely custom colors within each project, and we might need to make such colors adapt to the user’s current color scheme as well.

One way to do that is to make our UI code observe whenever the color scheme was changed, and to then update any custom colors that need to be adapted whenever that happens. For example like this when using SwiftUI:

struct ArticleListItem: View {
    var article: Article
    @Environment(\.colorScheme) private var colorScheme
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(titleColor)
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
    
    private var titleColor: Color {
        switch colorScheme {
case .light:
    return Color(white: 0.2)
case .dark:
    return Color(white: 0.8)
@unknown default:
    return Color(white: 0.2)
}
    }
}

When using UIKit, we can perform the same kind of observation by overriding the traitCollectionDidChange(_:) method within one of our views or view controllers. We can then switch on the passed trait collection’s userInterfaceStyle.

However, while the above technique certainly works, things can quickly get quite messy and repetitive if we need to perform the same kind of observation within many different views. One way to address that when using SwiftUI would be to instead create a reusable view modifier that lets us specify separate foreground colors for light and dark mode, and to then make our modifier observe the current color scheme internally — like this:

struct AdaptiveForegroundColorModifier: ViewModifier {
    var lightModeColor: Color
    var darkModeColor: Color
    
    @Environment(\.colorScheme) private var colorScheme
    
    func body(content: Content) -> some View {
        content.foregroundColor(resolvedColor)
    }
    
    private var resolvedColor: Color {
        switch colorScheme {
        case .light:
            return lightModeColor
        case .dark:
            return darkModeColor
        @unknown default:
            return lightModeColor
        }
    }
}

extension View {
    func foregroundColor(
        light lightModeColor: Color,
        dark darkModeColor: Color
    ) -> some View {
        modifier(AdaptiveForegroundColorModifier(
            lightModeColor: lightModeColor, 
            darkModeColor: darkModeColor
        ))
    }
}

With the above modifier in place, we can now easily specify our adaptive foreground colors inline within our views, without requiring any observations or other complexities at each call site:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(
    light: Color(white: 0.2),
    dark: Color(white: 0.8)
)
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

While the above technique is very useful if we only want to work with colors in a limited set of ways, if we instead wanted to customize all sorts of colors — foreground, background, tinting, shape stroking and filling, and so on — then we might want to come up with a slightly more versatile solution.

For that, we could turn to UIColor instead, which offers a way to initialize a color with a dynamic closure that the system will call whenever the currently active UITraitCollection was changed. That in turn enables us to implement a custom initializer that — just like the pattern that we used above — lets us specify separate light and dark mode colors, which will then be resolved based on the current trait collection’s userInterfaceStyle:

extension UIColor {
    convenience init(
        light lightModeColor: @escaping @autoclosure () -> UIColor,
        dark darkModeColor: @escaping @autoclosure () -> UIColor
     ) {
        self.init { traitCollection in
            switch traitCollection.userInterfaceStyle {
            case .light:
                return lightModeColor()
            case .dark:
                return darkModeColor()
            @unknown default:
                return lightModeColor()
            }
        }
    }
}

A big benefit of the above solution is that it can easily be expanded to take other kinds of traits (such as various accessibility settings) into account, since the UITraitCollection instance that we’re passed contains much more information than just what color scheme that’s used.

What’s really great is that SwiftUI’s Color and UIColor can easily be bridged — meaning that we can also make the above solution fully SwiftUI-compatible with very little extra code:

extension Color {
    init(
        light lightModeColor: @escaping @autoclosure () -> Color,
        dark darkModeColor: @escaping @autoclosure () -> Color
    ) {
        self.init(UIColor(
            light: UIColor(lightModeColor()),
            dark: UIColor(darkModeColor())
        ))
    }
}

Note that, in the iOS 15 SDK, the above Color initializer has been deprecated in favor of a new version called init(uiColor:), which works the exact same way.

With the above in place, we can now create adaptive Color and UIColor instances wherever we’d like, simply by doing this:

struct ArticleListItem: View {
    var article: Article
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text(article.title)
                .font(.headline)
                .foregroundColor(Color(
    light: Color(white: 0.2),
    dark: Color(white: 0.8)
))
            Text(article.description)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

If we then wanted to take things even further, we could even fully abstract our color definitions from our views by moving them into static properties that act the same way as primary, secondary, and the other dynamic colors that ship as part of the system:

extension Color {
    static var title: Self {
        Self(light: Color(white: 0.2),
             dark: Color(white: 0.8))
    }
}

The above is definitely the architecture that I prefer to use when building apps. By contextualizing each color into properties like title, background, appTint, and so on, I find that it’s much easier to keep an app’s colors consistent and well-organized over time.

Support Swift by Sundell by checking out this sponsor:

RevenueCat

RevenueCat: Easily build and manage iOS and Android in-app purchases. With just a few lines of code RevenueCat provides IAP infrastructure, customer analytics, data integrations, and gives you time back from dealing with edge cases and updates across all platforms.

Conclusion

Defining custom colors might initially seem like a simple problem to solve, but as the execution environments that our apps are run in continue to become more and more dynamic, the way we have to make our colors adapt to those different environments will likely continue to increase in complexity.

Hopefully this article has given you some inspiration on how to create such dynamic, adaptive colors, and if you have any questions, comments, or feedback, then feel free to reach out.

Thanks for reading!