Teabyte

Mobile app development for iOS and Swift

Effortless SwiftUI Theming

Building on my previous post, let's take a closer look at theming in SwiftUI. In this discussion, I'll show you how to create a customizable solution that integrates seamlessly with the system. The concepts covered here are implemented in my open-source library, which you can explore on GitHub.

What's Needed

Before diving into the code, it's a good idea to clarify our requirements. To create a robust theming solution for SwiftUI, the following key points should be addressed:

  1. A clearly defined concept of a theme.
  2. Integration with the SwiftUI lifecycle.
  3. Automatic application of themes across all Scenes, including instances of .sheet and .fullScreenCover views.
  4. Adherence to system accessibility guidelines.

Implementing a Theme Definition

With our goals in mind, let's start by defining the core of our theming solution — a Theme. This structure encapsulates the values that define a theme.

Theme.swift
public struct Theme: Sendable, Identifiable {
    // MARK: - Public Properties
    /// A unique ID for this theme, derived from its name.
    public var id: String { name }
 
    /// The name of the theme.
    public let name: String
 
    // MARK: - Private Properties
 
    /// A mapping of color styles to colors within this theme.
    private let colors: ColorMap
}
 
extension Theme {
    /// Defines a mapping from `ThemeColorStyle` to `ThemeColor`.
    public typealias ColorMap = [ThemeColorStyle: ThemeColor]
}

The highlight here is the ColorMap type, which links ThemeColorStyle (a semantic representation of colors) to ThemeColor. As explained in the previous post, ThemeColorStyle provides an abstract way of describing colors. Here's how we define it:

ThemeColorStyle.swift
public struct ThemeColorStyle: Identifiable, Sendable {
    /// A unique ID for this style, derived from its name.
    public var id: String { name }
 
    /// The name of the color style.
    public let name: String
 
    // MARK: - Initializer
    public init(name: String) {
        self.name = name
    }
}

Using a struct instead of an enum allows developers to define their styles, providing flexibility without hard-coded enumerations. This approach enables familiar dot notation by allowing developers to define static properties in ThemeColorStyle.

ThemeColorStyle.swift
extension ThemeColorStyle {
    /// Style for primary label colors in the app.
    static let primaryLabel: Self = Self(name: "primaryLabel")
}

The final piece is the ThemeColor, which represents a color's various variants for light/dark modes, accessibility contrast, and interface levels.

ThemeColor.swift
public struct ThemeColor: Sendable {
     /// The color used in light mode.
    public let lightColor: Color
 
    /// The color used in light mode with high contrast enabled.
    public let lightHighContrast: Color?
 
    /// The color used in light mode in elevated interface levels.
    public let lightElevated: Color?
 
    /// The color used in light mode with high contrast enabled and elevated interface levels.
    public let lightElevatedHighContrast: Color?
 
    /// The color used in dark mode.
    public let darkColor: Color
 
    /// The color used in dark mode with high contrast enabled.
    public let darkHighContrast: Color?
 
    /// The color used in dark mode in elevated interface levels.
    public let darkElevated: Color?
 
    /// The color used in dark mode with high contrast enabled and elevated interface levels.
    public let darkElevatedHighContrast: Color?
 
    // Initializer for all variants.
    public init(
        lightColor: Color,
        lightHighContrast: Color? = nil,
        lightElevated: Color? = nil,
        lightElevatedHighContrast: Color? = nil,
        darkColor: Color,
        darkHighContrast: Color? = nil,
        darkElevated: Color? = nil,
        darkElevatedHighContrast: Color? = nil
    ) {
        self.lightColor = lightColor
        self.lightHighContrast = lightHighContrast
        self.lightElevated = lightElevated
        self.lightElevatedHighContrast = lightElevatedHighContrast
        self.darkColor = darkColor
        self.darkHighContrast = darkHighContrast
        self.darkElevated = darkElevated
        self.darkElevatedHighContrast = darkElevatedHighContrast
    }
}

The optional properties reduce complexity for consumers, allowing them to define only what's necessary.

Creating a ThemeManager

Next, we'll implement a ThemeManager to handle theme switching. This class ensures that changes in theme automatically update the app's views.

ThemeManager.swift
@Observable
@MainActor
public class ThemeManager {
    /// The selected theme.
    public var selectedTheme: Theme
 
    /// Create a new theme manager instance.
    ///
    /// - Parameters:
    ///   - initialTheme: The theme that is initially selected.
    public init(initialTheme: Theme) {
        self.selectedTheme = initialTheme
    }
}

The @Observable annotation ensures SwiftUI updates views when selectedTheme changes.

After implementing the ThemeManager, the next step is to integrate it smoothly into the SwiftUI lifecycle. We aim to make the manager easy to incorporate into the app's structure.

In SwiftUI applications, the Scene serves as one of the highest levels in the app hierarchy. It encompasses everything displayed within a single window, including not just the "base" views but also elevated views like modals. This makes Scene an ideal point for integrating the ThemeManager.

To achieve this, we'll integrate the manager into the SwiftUI lifecycle by extending the Scene type with a helper.

ThemeManager+Scene.swift
extension Scene {
    /// Adds theming support to a scene.
    public func withThemeManager(themeManager: ThemeManager) -> some Scene {
        ThemedScene(themeManager: themeManager) { self }
    }
}
 
struct ThemedScene<Content: Scene>: Scene {
    @State private var themeManager: ThemeManager
    private let scene: Content
 
    init(themeManager: ThemeManager, @SceneBuilder content: () -> Content) {
        self._themeManager = State(initialValue: themeManager)
        self.scene = content()
    }
 
    var body: some Scene {
        scene
            .environment(themeManager)
            .environment(\.theme, themeManager.selectedTheme)
    }
}
 
extension EnvironmentValues {
    /// Provides access to the current theme.
    @Entry public var theme: Theme?
}

With this, integrating a theme manager into an app is straightforward:

App.swift
@main
struct ThemingDemoApp: App {
    @State var myThemeManager: ThemeManager = ThemeManager(initialTheme: .default)
 
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .withThemeManager(themeManager: myThemeManager)
    }
}

Only two lines of code are necessary to ulimately connect the theming solution to the SwiftUI lifecycle on the top most level.

Bridging SwiftUI and Theming

Finally, let's connect SwiftUI's color resolution to our theming system using the resolve(in:) method of ShapeStyle. The concepts used here are based on the ThemeColorShapeStyle discussed in the previous post.

To connect the Theme to SwiftUI’s environment, as provided by the ThemeManager, we can hook into the resolution cycle. Before diving into this integration, we’ll add a small helper method to the Theme definition. This method will simplify retrieving a color based on a specific style.

Theme.swift
struct Theme {
    ...
 
    /// Get a color for the requested color style and traits.
    ///
    /// - Parameters:
    ///   - style: The style to resolve a color for.
    ///   - traits: The traits used to resolve the color.
    /// - Returns: A color for the given parameters. Returns nil if no matching color is found.
    func color(for style: ThemeColorStyle, with traits: ThemeTraits) -> Color? {
        colors[style]?.resolve(with: traits)
    }
}

ThemeTraits serves as a small helper that extracts the necessary properties for determining a color from the EnvironmentValues.

ThemeTraits.swift
public typealias ThemeTraits = (
    colorScheme: ColorScheme,
    colorSchemeContrast: ColorSchemeContrast,
    interfaceLevel: UIUserInterfaceLevel
)
 
extension EnvironmentValues {
    /// The traits important for themes.
    var themeTraits: ThemeTraits {
        return (self.colorScheme, self.colorSchemeContrast, self.userInterfaceLevel)
    }
}

Additionally, we define a resolve(with:) method on ThemeColor that aims to replicate the functionality of the resolvedColor(with:) method from UIColor. This method serves as a single source of truth, calculating the appropriate color based on the current environment. It also includes a fallback mechanism to handle situations where a consumer has not provided a corresponding color.

ThemeColor.swift
/// Resolves a color based on the given traits.
///
/// - Parameters:
///   - traits: The traits used to resolve the color.
/// - Returns: A color matching the given traits.
func resolve(with traits: ThemeTraits) -> Color {
    return switch (traits.colorScheme, traits.colorSchemeContrast, traits.interfaceLevel) {
    case (.light, .standard, .base):
        lightColor
 
    case (.light, .standard, .elevated):
        lightElevated ?? lightColor
 
    case (.light, .increased, .base):
        lightHighContrast ?? lightColor
 
    case (.light, .increased, .elevated):
        lightElevatedHighContrast ?? lightColor
 
    case (.dark, .standard, .base):
        darkColor
 
    case (.dark, .standard, .elevated):
        darkElevated ?? darkColor
 
    case (.dark, .increased, .base):
        darkHighContrast ?? darkColor
 
    case (.dark, .increased, .elevated):
        darkElevatedHighContrast ?? darkColor
 
    default:
        fatalError()
    }
}

After having the helpers set up, we can now implement the resolve(in:) method of a custom type which then connects the dots.

ThemeColorShapeStyle.swift
/// A shape style representing a theme color.
///
/// It is used to resolve a theme color when ``themeColor(for:)`` is used.
public struct ThemeColorShapeStyle: ShapeStyle {
    // MARK: - Private Properties
    private let style: ThemeColorStyle
 
    // MARK: - Initialisers
    public init(style: ThemeColorStyle) {
        self.style = style
    }
 
    // MARK: - Resolve
    public func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        guard let theme = environment.theme else {
            fatalError()
        }
 
        guard let color = theme.color(for: style, with: environment.themeTraits) else {
            fatalError()
        }
        return color
    }
}

This approach enables SwiftUI to automatically select the appropriate color whenever the EnvironmentValues change. To simplify usage further, we will create extension methods on ShapeStyle, allowing users to utilize theme-based colors just as they would with any default color in SwiftUI.

extension ShapeStyle where Self == ThemeColorShapeStyle {
    /// A context-dependent theme color shape suitable for use in UI elements.
    ///
    /// - Parameters:
    ///   - style: The color style to be used.
    /// - Returns: A shape base on the current environment and color style.
    public static func themeColor(for style: ThemeColorStyle) -> ThemeColorShapeStyle {
        ThemeColorShapeStyle(style: style)
    }
}

Consumers can now utilize a theme color using a straightforward dot notation.

Text("Hello World")
    .foregroundStyle(.themeColor(for: .primaryLabel))

Conclusion

In this post, we discussed how to create a theming system for SwiftUI that integrates smoothly and aligns with system conventions. You can explore the concepts we've implemented in my theming library, which may also serve as inspiration for your own projects.

Have feedback or suggestions? Feel free to reach out or contribute via GitHub. Thanks for reading, and see you next time! 👋