Theming SwiftUI applications

Michael Kao
intive Developers
8 min readSep 6, 2023

--

When developing apps for our clients at intive, we strive to a consistent and clear design language. Throughout the development process, our designers typically provide components and screen designs optimized for the “light” mode, which ensures an optimal viewing experience in normal light environments.
Another aspect we consider is supporting dark mode, which offers an alternative version of the app that visually complements the light mode. These are typically the primary variables that affect the user interfaces we build, although there may be additional factors to consider.

Especially when working with larger clients operating at a significant scale across multiple countries, we recognize the need for increased flexibility in theming mobile applications. For example, some apps may require different appearances based on the country they are used in. Country A might need a distinct accent color for its interactive UI elements compared to Country B, while still maintaining a common design language. Another scenario could involve apps that need to adapt their appearance during specific time periods, such as around Black Friday or during the Christmas holidays.

In this article, we will explore how to achieve this kind of theming for modern SwiftUI applications. Specifically, we will focus on theming buttons, although the proposed solution can also be applied to other system UI elements or custom ones.

Before delving into the topic right away, let’s take a step back and review the existing theming capabilities provided by SwiftUI.

Button Styles

In SwiftUI we have the concept of “Styles“ for defining a views appearance (and interaction behavior). For buttons this would be the PrimitiveButtonStyle, which is a protocol which comes with a couple of system provided implementations.
By default SwiftUI will select a default style based on the current context. The most likely default style will be the BorderlessButtonStyle¹.

Button("Some Button") {}
Default button in SwiftUI

To present a button with a solid rounded background, we can use the BorderedProminentButtonStyle, which is also statically available as borderedProminent.

Button("Some Button") {}
.buttonStyle(.borderedProminent)
Button with “bordered prominent” style

Custom Button Styles

Tweaking the system styles is possible to some extend, but we often require more flexibility for customization. To do so, we can implement our own button styles by conforming a type to the PrimitiveButtonStyle or ButtonStyle protocol. The latter is more about defining the appearance while keeping the default interaction behavior. PrimitiveButtonStyle is about specifying both¹.

Our own implementation of a custom button style could look something like the following PrimaryButtonStyle:

struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
Spacer()
configuration.label
Spacer()
}
.font(.system(.title2, design: .monospaced).bold())
.padding([.vertical], 24)
.foregroundColor(Color.teal)
.background {
Capsule()
.stroke(Color.teal, lineWidth: 3)
}
}
}

The ButtonStyle configuration provides us with the label that is centered within an HStack. We also add a Capsule shape around the button, with a fixed teal color and some adjusted font design.

This button style can be applied with the buttonStyle view modifier.

Button("Primary Button") {}
.buttonStyle(PrimaryButtonStyle())
Button with custom “primary”  button style

By extending the ButtonStyle protocol we can also shorten the expression as it’s done for system defined button styles.

extension ButtonStyle where Self == PrimaryButtonStyle {
static var primary: Self { .init() }
}

Which allows to the following expression:

Button("Primary Button") {}
.buttonStyle(.primary)

Theming

With that in mind, let’s look at the previously described scenario, where we have to support visually different versions of our UI. For demonstration let’s say we have an app, that has a default design, but during black Friday parts of the UI should have a darker look and feel.

Let’s first define a type that holds the moving parts of our UI, which can be themed. For that we can use a struct which holds the properties that can be adapted per component, in our case let’s start with just the color for the primary button.

struct Theme {
var button: Button

struct Button {
var primary: Primary

struct Primary {
var color: Color
}
}
}

Note that this is a more deeply nested structure that really needed. But it’s a structure we can easily built upon and extend.

Our default theme could look something like this:

extension Theme {
static let `default` = Self(
button: .init(
primary: .init(color: .teal)
)
)
}

To use this theme inside our custom button style we can leverage SwiftUIs “EnvironmentValues”. This has a couple of advantages, but we will come to that in a bit.
To make our Theme available to the SwiftUI environment, we need to extend the SwiftUI’s EnvironmentValues by implementing an EnvironmentKey.

private struct ThemeEnvironmentKey: EnvironmentKey {
static var defaultValue = Theme.default
}

extension EnvironmentValues {
var theme: Theme {
get { self[ThemeEnvironmentKey.self] }
set { self[ThemeEnvironmentKey.self] = newValue }
}
}

The default value that is required by the EnvironmentKey, will be the previously defined static Theme.default. Further we need to define a property on EnvironmentValues which internally accesses the underlying structure through subscript syntax.

To make use of the new environment value, the PrimaryButtonStyle only needs a few adjustments:
First we plug out the environment value through the Environment property wrapper:

struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.theme) private var theme
...
}

Accessing EnvironmentValues from within a ButtonStyle is already supported since iOS 13².

Then we use the theme in those places that need to change depending on the selected theme.

  ...
func makeBody(configuration: Configuration) -> some View {
...
.foregroundColor(self.theme.button.primary.color)
.background {
Capsule()
.stroke(self.theme.button.primary.color, lineWidth: 3)
}
}
}

We can also use Swifts key paths and access the theme that we are actually interested in directly through the property wrapper.

struct PrimaryButtonStyle: ButtonStyle {
@Environment(\.theme.button.primary) private var theme

func makeBody(configuration: Configuration) -> some View {
...
.foregroundColor(self.theme.color)
.background {
Capsule()
.stroke(self.theme.color, lineWidth: 3)
}
}
}

The only thing that is missing is the definition for our new theme. Let’s call it blackFriday.

extension Theme {
static let blackFriday = Self(
button: .init(
primary: .init(color: .black)
)
)
}

Switching themes

With that we can use our Theme and try it out in a SwiftUI preview.

struct ThemingPreviews: PreviewProvider {
struct ContentView: View {
var body: some View {
VStack {
Button("Primary Button") {}
.buttonStyle(.primary)
}
.padding()
}
}

static var previews: some View {
ContentView()
}
}

Without any adjustments, the button style will use the default theme. To switch the theme we can use the environment view modifier and set the theme.

ContentView()
.environment(\.theme, .blackFriday)
A “Primary Button” with the black Friday theme.

One great benefit of leveraging the SwiftUI environment for this purpose, is that it will propagate the theme down to any descendant of the ContentView. We only need to set the theme once, preferably high in the the view tree, and any view showing a button with the “primary” button style will automatically adjust it’s appearance.

In a real world application we would set the theme environment value depending on the current date and time or based on the country that app is build for or is distributed in.

Flexibility

Another interesting aspect of using the SwiftUI environment, is that we still have great flexibility to alter the theme in certain areas of our app where we need to. Imagine we have a “blackFriday” modal, which should show the “blackFriday” theme no matter what was set higher up in the hierarchy. This is possible by setting the theme environment value on the view presented in the sheet:

struct ThemingPreviews: PreviewProvider {
struct ContentView: View {
@State private var isPresented = false

var body: some View {
VStack {
Button("Primary Button") { isPresented = true }
.buttonStyle(.primary)
}
.padding()
.sheet(isPresented: $isPresented) {
NavigationView {
Button("Primary Button") { isPresented = false }
.padding()
.buttonStyle(.primary)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Modal")
}
.environment(\.theme, .blackFriday)
}
}
}

static var previews: some View {
ContentView()
.environment(\.theme, .default)
}
}
Modal with button with different theme
Using different themes in certain areas, for example within a specific modal

It’s would even be possible to make ad-hoc adjustments to the current theme when needed. This is possible because we use structs for defining our Theme.

Button("Primary Button") {}
.buttonStyle(.primary)
.environment(\.theme.button.primary.color, .pink)

Theme structure

The structure of the Theme type is completely up to us and the needs of our application. The “Primary” button can easily be extended by adding additional properties to the Theme.Button.Primary type. If we would need to support additional “Secondary” or “Tertiary” buttons we can add these to the Theme.Button type.

struct Theme {
var button: Button

struct Button {
var primary: Primary
var secondary: Secondary
var tertiary: Tertiary

struct Primary {
var color: Color
var borderWidth: CGFloat
}

struct Primary {
var color: Color
}

struct Tertiary {
var color: Color
}
}
}

Another possible extension to the Theme could be done for our custom views. Imagine we have a settings list in our app that should change its item appearances based on the used theme. To support this, we could add a Theme.Settings.Item type that holds the themed properties of a SettingsItem view.

struct Theme {
...
var settings: Settings

struct Settings {
var item: Item

struct Item {
var backgroundColor: Color
}
}
}

If our settings item is built as a simple view, we can plug out the needed theme directly in our SettingsItem view by reaching out to the theme @Environment(\.theme.settings.item) private var theme.

Summary

By leveraging the SwiftUI environment we gain a simple way of communicating our theme down the view hierarchy. It is possible to change the theme dynamically and we are still able to specify the behaviour for certain areas of our applications. For further reading I highly recommend the excellent posts by moving parts on styling³ and composability⁴.

[1] Exploring SwiftUI’s Button styles — https://www.fivestars.blog/articles/button-styles/

[2] Environment Objects and SwiftUI Styles — https://www.fivestars.blog/articles/environment-objects-and-swiftui-styles/

[3] Styling Compontents in SwiftUI — https://movingparts.io/styling-components-in-swiftui

[4] Composable Styles in SwiftUI — https://movingparts.io/composable-styles-in-swiftui

--

--