Adaptive Theming in SwiftUI

One key aspect of my new personal RSS reader application is its dynamic theming capability. When I saw the themes Current provides I knew I wanted something similar for my own personal project.

Fortunately SwiftUI comes with a variety of APIs which make this possible to achieve in a clean way. A few requirements up front:

  1. Themes should switch dynamically

  2. Themes should be available in a light and dark variant

  3. Variants of a theme should switch based on the user preferred color scheme

  4. I want easy access to the theme primitives in the code

Lets start with the data model for the theme:

struct Theme: Hashable {
    let name: String
    let description: ThemeDescription
    let tokens: UiTokens
    
    init(name: String, description: ThemeDescription, tokens: UiTokens) {
        self.name = name
        self.description = description
        self.tokens = tokens
    }
    
    static let paper: Theme = .init(
        name: "Paper",
        description: .init(
            light: NSLocalizedString("Warm parchment tones inspired by printed books. The default palette, perfect for a cozy, literary reading experience.", comment: ""),
            dark: NSLocalizedString("Warm charcoal with amber accents, like reading by candlelight. Ideal for evening reading sessions.", comment: "")
        ),
        tokens: .init(
            color: .init(
                bg: .init(
                    primary: .init(light: Color(hex: "#FAF8F5")!, dark: Color(hex: "#1C1915")!),
                    // ...
                ),
                // ...
            )
        )
    )
}

struct ThemeDescription: Hashable {
    let light: String
    let dark: String

    func resolved(for colorScheme: ColorScheme) -> String {
        colorScheme == .dark ? dark : light
    }
}

struct UiTokens: Hashable {
    let color: ColorToken
    // ...
}

struct ColorToken: Hashable {
    let bg: Background
    // ...
    
    struct Background: Hashable {
        let primary: ThemeAdaptiveStyle<Color>
        // ...
    }
}
// ...

Looking at this model you might notice the usage of ThemeAdaptiveStyle so what is this exactly? It's my wrapper struct to a light and a dark variant for a given color token:

struct ThemeAdaptiveStyle<Style: Sendable & Hashable>: Sendable, Hashable {
    let light: Style
    let dark: Style
}

extension ThemeAdaptiveStyle {
    func resolved(for colorScheme: ColorScheme) -> Style {
        colorScheme == .dark ? dark : light
    }
}

With this in place I can then proceed to create a custom SwiftUI ShapeStyle:

struct BgShapeStyle: ShapeStyle {
    nonisolated(unsafe) let keyPath: KeyPath<Theme, ThemeAdaptiveStyle<Color>>

    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        environment.theme[keyPath: keyPath].resolved(for: environment.colorScheme)
    }
}

To make this available we need an extension on ShapeStyle:

struct BgNamespace {
    var primary: BgShapeStyle { .init(keyPath: \.tokens.color.bg.primary) }
// ...
}

extension ShapeStyle where Self == BgShapeStyle {
    static var bg: BgNamespace { .init() }
}

Now we are able to set the primary background color via .background(.bg.primary) and it will automatically adapt to the light or dark variant thanks to the resolving of the environment color scheme in the custom shape style.

This won't compile currently as the custom shape style uses environment.theme to access the currently selected theme so lets add this key:

extension EnvironmentValues {
    @Entry var theme = Theme.paper
}

Everything thats left is to a) persist the selected theme and color scheme and b) provide a way to set this for the user:

@MainActor final class AppSettings: ObservableObject {
    private static let defaultTheme: Theme = .paper

    @Published var theme: Theme = defaultTheme
    @AppStorage private var themeName: String
    @AppStorage var colorScheme: ColorScheme?

    init(store: UserDefaults = .standard) {
        _themeName = AppStorage(wrappedValue: Self.defaultTheme.name, "themeName", store: store)
        _colorScheme = AppStorage("colorScheme", store: store)

        theme = Theme.allCases.first { $0.name == themeName } ?? Self.defaultTheme
    }
    
    func setTheme(_ theme: Theme) {
        self.theme = theme
        self.themeName = theme.name
    }
}

// NOTE: This allows @AppStorage to store an optional ColorScheme
extension ColorScheme: @retroactive RawRepresentable {
    public init?(rawValue: Int) {
        guard let userInterfaceStyle = UIUserInterfaceStyle(rawValue: rawValue),
              userInterfaceStyle != .unspecified,
              let colorScheme = ColorScheme(userInterfaceStyle)
        else {
            return nil
        }

        self = colorScheme
    }
    
    public var rawValue: Int {
        let userInterfaceStyle = UIUserInterfaceStyle(self)
        return userInterfaceStyle.rawValue
    }
}

With the theme and the color scheme stored in the UserDefaults we can use this new AppSettings class to store and retrieve the persisted values. I created a custom binding for this:

private var selectedTheme: Binding<Theme> {
    .init {
        appSettings.theme
    } set: { theme in
        appSettings.setTheme(theme)
    }
}

Make sure to set the environment variables as early as possible to the persisted ones in your application:

@StateObject private var appSettings: AppSettings = .live
@Environment(\.colorScheme) private var colorScheme
    
.environment(\.theme, appSettings.theme)
.preferredColorScheme(appSettings.colorScheme ?? colorScheme)
.tint(appSettings.theme.tokens.color.text.accent.resolved(for: colorScheme))

The ?? colorScheme is needed in case the colorScheme is nil when we want to apply the system/automatic color scheme mode. Make sure to also apply this to opened sheets as they sometimes do not update correctly otherwise.

This is quite a lot of code but ultimately we have a dynamic theming system at hand which we can easily extend and which adapts to the users preferences. Using our theme constants couldn't be easier as it works exactly like builtin SwiftUI shape styles.

If you are curious how this theming solution looks like in practice head over to my article Building an iOS RSS reader which provides a short demo video showcasing the theming in action.

Posted in swift