As the year comes to an end, I am using my free days to explore some concepts in SwiftUI that I set aside for a longer time now. One of those topics was custom theming in SwiftUI, especially related to using colours. While SwiftUI offers a very versatile colour system out of the box, especially with HierarchicalShapeStyle
I wanted to explore a fully custom solution.
One of the main tasks in a custom theming system is to provide custom colours based on the current environment. This includes the interface style (light or dark mode), interface level (base or elevated), and whether the user has enabled the high contrast accessibility setting to account for a11y.
The main issue I had when it came to custom theming in SwiftUI was an initializer that could adapt the colour value dynamically based on exactly this environment. In UIKit we can utilise init(dynamicprovider:)
to change colour values - while it's possible to bridge this for iOS and visionOS it is not possible to use on watchOS. I was searching for a general, first-class SwiftUI solution to achieve similar behavior. While searching I found a pretty neat solution using ShapeStyle
which I want to demonstrate in this small post.
Setup
Before diving into the implementation, let's take a look at ShapeStyle
and what we can use to fulfill our requirements. When implementing a custom ShapeStyle
we need to implement the resolve(in:)
method. It is responsible for calculating the correct shape style based on a given EnvironmentValues
instance. This is the perfect hook to resolve a different colour based on the environment dynamically.
The EnvironmentValues
also contain properties we are interested in dynamically deciding which colour to return. These are:
As you might have noticed we are missing one crucial property for iOS and watchOS - the interface level.
Bridging the interface level
The interface level, formerly known as UIUserInterfaceLevel
is unfortunately not natively available to access with SwiftUIs EnvironmentValues
. Therefore we need to bridge this from UIKit. The only place where we need to touch UIKit in this post. Since we want to create a platform-independent solution we also need to take care of the different platforms we support. Especially since there is no interface level equivalent available on watchOS, the bridge is not available for this platform and has to be excluded for this platform.
Implementation
After having the base setup, we can now start to implement our custom color theming approach.
First, we start by defining some color styles, we want to enable in our theme.
You can get very creative here and define as many different colours as you need. What I strongly advise you to follow is the semantic colour naming scheme. This helps to better reason about the given colour and is much easier to work with than using names like "blue-500", "red-100" etc.
After defining the ThemeColorStyle
type, we can utilise this in a custom ShapeStyle
implementation.
In order to more type safely work with EnvironmentValues
and our needed properties, I would like to introduce some convenience helpers for it. This separates the traits we need from the environment and kind of acts like an interface when resolving the colours. It is not necessarily needed so it's up to you if follow the same pattern here. We also need to pay attention to the different platforms since the interface level is not available on watchOS
.
Resolving a Color
When it comes to resolving a colour, it becomes a little bit tedious. Since we have quite some cases to consider we need to account for the different combinations of the color style and the theme color traits. This in the end results in quite some number of lines of code. There is definitely some improvement potential in this, but I want to highlight the basics behind the idea, therefore I will showcase the very bare minimum code here.
The logic itself is rather simple though. For every combination of traits and the corresponding colour style, we need to resolve the corresponding colour in the resolve(in:)
method of our ShapeStyle
implementation.
As you can see, resolving colours involves quite some code to write. As said, there is some optimization potential here. We could for example think of utilizing writing a macro to cover all of the resolution per color style for us.
One additional note for the watchOS implementation. Even though, watchOS at the moment only offers dark mode, the colour scheme enum is still also available for light mode on watchOS. Therefore it is necessary to either implement it explicitly or use the default
case to catch it.
DX Improvements
After having the resolution code implemented, we introduced some extensions to let us use the custom colour approach more naturally in SwiftUI. For this, create an extension on ShapeStyle
:
This enables us to use our custom-themed colours naturally whenever the regular colours of SwiftUI could also be used.
Conclusion
We have seen how we can utilise first-class SwiftUI possibilities to implement a custom colour theming in SwiftUI. It covers all of our needs by letting us resolve a colour based on the colour scheme, interface level, and accessibility contrast as well as being compatible with all major platforms. Based on this initial implementation we can also implement a complete theming system, which would allow us to dynamically switch between different themes at ease at runtime of the app. I am considering also writing about this in the future. Let me know if I should do it.
Please feel free to reach out to me if you find any mistakes, or have questions or suggestions. You can find ways to contact me on my About 🙂
Wish you a happy end of the year and see you next time! 👋