Teabyte

Mobile app development for iOS and Swift

The Power of ShapeStyle for Colour theming in SwiftUI

As the year comes to an end, I am using my free days to explore some concepts in SwiftUI that I set aside for a longer time now. One of those topics was custom theming in SwiftUI, especially related to using colours. While SwiftUI offers a very versatile colour system out of the box, especially with HierarchicalShapeStyle I wanted to explore a fully custom solution.

One of the main tasks in a custom theming system is to provide custom colours based on the current environment. This includes the interface style (light or dark mode), interface level (base or elevated), and whether the user has enabled the high contrast accessibility setting to account for a11y.

The main issue I had when it came to custom theming in SwiftUI was an initializer that could adapt the colour value dynamically based on exactly this environment. In UIKit we can utilise init(dynamicprovider:) to change colour values - while it's possible to bridge this for iOS and visionOS it is not possible to use on watchOS. I was searching for a general, first-class SwiftUI solution to achieve similar behavior. While searching I found a pretty neat solution using ShapeStyle which I want to demonstrate in this small post.

Setup

Before diving into the implementation, let's take a look at ShapeStyle and what we can use to fulfill our requirements. When implementing a custom ShapeStyle we need to implement the resolve(in:) method. It is responsible for calculating the correct shape style based on a given EnvironmentValues instance. This is the perfect hook to resolve a different colour based on the environment dynamically.

struct MyCustomShapeStyl: ShapeStyle {
    // MARK: - Resolve
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        return <some ShapeStyle>
 }
}

The EnvironmentValues also contain properties we are interested in dynamically deciding which colour to return. These are:

As you might have noticed we are missing one crucial property for iOS and watchOS - the interface level.

Bridging the interface level

The interface level, formerly known as UIUserInterfaceLevel is unfortunately not natively available to access with SwiftUIs EnvironmentValues. Therefore we need to bridge this from UIKit. The only place where we need to touch UIKit in this post. Since we want to create a platform-independent solution we also need to take care of the different platforms we support. Especially since there is no interface level equivalent available on watchOS, the bridge is not available for this platform and has to be excluded for this platform.

EnvironmentValues+UserInterfaceLevel.swift
#if !os(watchOS)
struct UserInterfaceLevel: EnvironmentKey {
    static var defaultValue: UIUserInterfaceLevel {
        return UITraitCollection.current.userInterfaceLevel
    }
}
 
extension EnvironmentValues {
    var userInterfaceLevel: UIUserInterfaceLevel { self[UserInterfaceLevel.self] }
}
#endif

Implementation

After having the base setup, we can now start to implement our custom color theming approach.

First, we start by defining some color styles, we want to enable in our theme.

ThemeColorStyle.swift
enum ThemeColorStyle {
    /// The primary color.
    case primary
 
    /// A positive color.
    case positive
 
    /// A negative color.
    case negative
 
    /// A color used for background.
    case background
}

You can get very creative here and define as many different colours as you need. What I strongly advise you to follow is the semantic colour naming scheme. This helps to better reason about the given colour and is much easier to work with than using names like "blue-500", "red-100" etc.

After defining the ThemeColorStyle type, we can utilise this in a custom ShapeStyle implementation.

ThemeColorShapeStyle.swift
struct ThemeColorShapeStyle: ShapeStyle {
    // MARK: - Private Properties
    private let colorStyle: ThemeColorStyle
 
    // MARK: - Initialisers
    init(style: ThemeColorStyle) {
        self.style = style
    }
 
    // MARK: - Resolve
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        // to be implemented
    }
}

In order to more type safely work with EnvironmentValues and our needed properties, I would like to introduce some convenience helpers for it. This separates the traits we need from the environment and kind of acts like an interface when resolving the colours. It is not necessarily needed so it's up to you if follow the same pattern here. We also need to pay attention to the different platforms since the interface level is not available on watchOS.

ThemeHelpers.swift
#if os(visionOS) || os(iOS)
/// Color traits for a Theme color.
public typealias ThemeColorTraits = (
    colorScheme: ColorScheme,
    colorSchemeContrast: ColorSchemeContrast,
    interfaceLevel: UIUserInterfaceLevel
)
#elseif os(watchOS)
/// Color traits for a Theme color.
public typealias ThemeColorTraits = (
    colorScheme: ColorScheme,
    colorSchemeContrast: ColorSchemeContrast
)
#endif
 
extension EnvironmentValues {
    /// Theme traits of the environment.
    var themeTraits: ThemeColorTraits {
    #if os(visionOS) || os(iOS)
        return (self.colorScheme, self.colorSchemeContrast, self.userInterfaceLevel)
    #elseif os(watchOS)
        return (self.colorScheme, self.colorSchemeContrast)
    #endif
    }
}

Resolving a Color

When it comes to resolving a colour, it becomes a little bit tedious. Since we have quite some cases to consider we need to account for the different combinations of the color style and the theme color traits. This in the end results in quite some number of lines of code. There is definitely some improvement potential in this, but I want to highlight the basics behind the idea, therefore I will showcase the very bare minimum code here.

The logic itself is rather simple though. For every combination of traits and the corresponding colour style, we need to resolve the corresponding colour in the resolve(in:) method of our ShapeStyle implementation.

ThemeColorShapeStyle.swift
struct ThemeColorShapeStyle: ShapeStyle {
    // MARK: - Resolve
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        return switch colorStyle {
        case .primary:
            resolvePrimaryColor(with: environment.themeTraits)
        case .secondary:
            ...
        case ...
        }
    }
 
    #if os(visionOS) || os(iOS)
    private func resolvePrimaryColor(with traits: ThemeColorTraits) -> some ShapeStyle {
        return switch (traits.colorScheme, traits.colorSchemeContrast, traits.interfaceLevel) {
        case (.light, .standard, .base):
            lightColor
        case (.light, .standard, .elevated):
            lightElevatedColor
        case (.light, .increased, .base):
            lightIncreasedContrastColor
        case (.light, .increased, .elevated):
            lightElevatedIncreasedContrastColor
        case (.dark, .standard, .base):
            darkColor
        case (.dark, .standard, .elevated):
            darkElevatedColor
        case (.dark, .increased, .base):
            darkIncreasedContrastColor
        case (.dark, .increased, .elevated):
            darkElevatedIncreasedContrastColor
        default: // To cover not needed cases, should not be used actually
            fallbackColor
        }
    }
    #endif
 
    #if os(watchOS)
    private func resolvePrimaryColor(with traits: ThemeColorTraits) -> some ShapeStyle {
        return switch (traits.colorScheme, traits.colorSchemeContrast, traits.interfaceLevel) {
        case (.light, .standard)
            lightColor
        case (.light, .increased)
            lightIncreasedContrastColor
        case (.dark, .standard):
            darkColor
        case (.dark, .increased, .base):
            darkIncreasedContrastColor
        default: // To cover not needed cases, should not be used actually
            fallbackColor
        }
    }
    #endif
    ... other color styles
}

As you can see, resolving colours involves quite some code to write. As said, there is some optimization potential here. We could for example think of utilizing writing a macro to cover all of the resolution per color style for us.

One additional note for the watchOS implementation. Even though, watchOS at the moment only offers dark mode, the colour scheme enum is still also available for light mode on watchOS. Therefore it is necessary to either implement it explicitly or use the default case to catch it.

DX Improvements

After having the resolution code implemented, we introduced some extensions to let us use the custom colour approach more naturally in SwiftUI. For this, create an extension on ShapeStyle:

ThemeColorShapeStyle.swift
extension ShapeStyle where Self == ThemeColorShapeStyle {
    public static func themeColor(for style: ThemeColorStyle) -> ThemeColorShapeStyle {
        ThemeColorShapeStyle(style: style)
    }
}

This enables us to use our custom-themed colours naturally whenever the regular colours of SwiftUI could also be used.

// foregroundStyle usage
Button {
    positiveAction()
} label: {
    Text("Confirm")
        .foregroundStyle(.themeColor(for: .positive))
}
 
// background usage
List {
    Text("Hello World")
}
.scrollContentBackground(.hidden)
.background(.themeColor(for: .background))
 
// Other usages
Circle()
    .fill(.themeColor(for: .positive))

Conclusion

We have seen how we can utilise first-class SwiftUI possibilities to implement a custom colour theming in SwiftUI. It covers all of our needs by letting us resolve a colour based on the colour scheme, interface level, and accessibility contrast as well as being compatible with all major platforms. Based on this initial implementation we can also implement a complete theming system, which would allow us to dynamically switch between different themes at ease at runtime of the app. I am considering also writing about this in the future. Let me know if I should do it.

Please feel free to reach out to me if you find any mistakes, or have questions or suggestions. You can find ways to contact me on my About 🙂

Wish you a happy end of the year and see you next time! 👋