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:
- A clearly defined concept of a theme.
- Integration with the SwiftUI lifecycle.
- Automatic application of themes across all
Scenes
, including instances of.sheet
and.fullScreenCover
views. - 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.
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:
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
.
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.
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.
@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.
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:
@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.
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
.
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.
/// 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.
/// 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! 👋