A robust theme system is important to consider when creating an app. It allows all your colours, fonts and assets to belong in one place so they can be easily updated. It can also be extended to allow the user to change the look and feel of your app. It can also help you as a developer to stick to consist styles if you don’t have a designer on hand.

Let’s take a look at the requirements for a theme system.

  • It should be easy to see all the app colours and fonts in one place
  • It should be easy to change the theme for the developer
  • It should be easy to change the theme for a user
  • The user’s theme choice should be persisted
  • When the theme is changed, the app should responsively update all it’s styles

Right. Let’s get started!

Initial Structure

We will start by defining what is required for a theme. A theme will have separate models for listing out all the colours in the app as well as all the fonts. If you have a styleguide from your designers this will be super easy to implement but if not, take a look at various parts of the app and determine which colours and font styles are used throughout and give them sensible names.

struct ThemeFonts {
    var body: Font
    var title: Font
    var subtitle: Font
}
struct ThemeColors {
    let tint: Color
    let primaryText: Color
    let background: Color
}

We can then make a protocol for a Theme that uses those two structs.

protocol Theme {
    var font: ThemeFonts { get }
    var color: ThemeColors { get }
}

Let’s create the default or main theme for the app. I like to keep this in a separate file so it’s really easy to see all the colours and fonts in one place. You could split fonts and colours up into separate files using extensions or even just make them entirely separate objects.

struct DefaultTheme: Theme {
    let font = ThemeFonts(
        body: .system(size: 16, weight: .regular),
        title: .system(size: 26, weight: .bold),
        subtitle: .system(size: 22, weight: .medium)
    )
    let color = ThemeColors(
        tint: .blue,
        primaryText: .gray,
        background: Color(.systemBackground)
    )
}

Accessing and Using the Theme in SwiftUI

We will make use the of SwiftUI environment to access our theme. Firstly, we need a provider to provide the current theme. We will make it observable too using the @Observable macro so that if the theme changes, all the views using the theme will be notified and update accordingly.

@Observable
final class ThemeProvider {
    var current: Theme = DefaultTheme()
}

Now we want to make an environment value for the theme, again using a macro to make the process less boiler-platey.

extension EnvironmentValues {
    @Entry var theme = ThemeProvider()
}

And now we can use our theme in any of our SwiftUI views. We just access it from the environment.

struct ContentView: View {
    @Environment(\.theme) var theme

    var body: some View {
        Text("Hello, World!")
            .foregroundStyle(theme.current.color.tint)
            .font(theme.current.font.body)
    }
}

With this structure, the colours and fonts are all in one place which means they can easily be changed or updated and these will affect all areas of the app.

Selecting a Theme

Allowing the user to select a theme for an app is a popular way to give a bit of cusomisation and control. With our structure in place this should be fairly easy.

To start, let’s create a new theme that implements our theme protocol.

struct SecondTheme: Theme {
    let font = ThemeFonts(
        body: .custom("AvenirNext-Body", size: 14),
        title: .custom("AvenirNext-Bold", size: 26),
        subtitle: .custom("AvenirNext-Medium", size: 22)
    )
    let color = ThemeColors(
        tint: Color("CustomTint"),
        primaryText: Color("CustomText"),
        background: Color("CustomBackground")
    )
}

Next, I’m going to make an enum to represent all available themes in the app. This will help when displaying the theme options as well as persisting the user’s choice (more on that later). The enum will have cases for each theme and a computed property to return the theme object. I will also add some protocol conformances. This is so the cases can be displayed in our UI.

enum ThemeOption: Int, CaseIterable, Identifiable, CustomStringConvertible {
    case defaultTheme
    case secondTheme

    var theme: Theme {
        switch self {
        case .defaultTheme:
            return DefaultTheme()
        case .secondTheme:
            return SecondTheme()
        }
    }

    var description: String {
        switch self {
        case .defaultTheme:
            "Default Theme"
        case .secondTheme:
            "Secondary Theme"
        }
    }

    var id: Self { self }
}

We can then add a method to our ThemeProvider to update the current theme with the enum.

func set(theme: ThemeOption) {
    current = theme.theme
}

Finally we can display the options in our UI and allow the user to select a theme. Because we made our enum CaseIterable, it is really easy to list all available theme options in our UI.

@Environment(\.theme) var theme

var body: some View {
    VStack {
        ForEach(ThemeOption.allCases) { themeOption in
            Button(themeOption.description) {
                withAnimation {
                    theme.set(theme: themeOption)
                }
            }
        }
    }
}

The theme now changes smoothly when selecting the different options. Nice!

Persisting the Theme Choice

To tidy everything up and finish our theming system, let’s persist the user’s theme choice so when they quit the app and reopen it, their choice is remembered.

I’m going to use UserDefaults as our persistence. Also, because we made each enum case an integer, it will be nice a straightforward to persist the choice. Let’s make some changes to our theme provider to save the choice.

@Observable
final class ThemeProvider {
    var current: Theme = DefaultTheme()

    private let defaults: UserDefaults
    private let themeKey: String = "SavedTheme"

    init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        current = getSavedTheme().theme
    }

    func set(theme: ThemeOption) {
        current = theme.theme
        defaults.set(theme.rawValue, forKey: themeKey)
    }

    func getSavedTheme() -> ThemeOption {
        let rawValue = defaults.integer(forKey: themeKey)
        return ThemeOption(rawValue: rawValue) ?? .defaultTheme
    }
}

And surprisingly, that’s all we need to save the user’s choice.

Conclusion

This post detailed how to make an updatable theme system in SwiftUI. You could split the font and colours up so the user can choose each independently but for a basic theming system, it does the job nicely.

Thanks for reading.