Modern iOS Theming with UITraitCollection
Fairly recently (in iOS 17), Apple introduced a pretty nifty way to handle theme changes in iOS apps. It used to be kind of a pain to deal with this (as chronicled in several well-written blog posts from Christian Selig and Shadowfacts).
With the introduction of UITraitAppearance
, things are no longer so bad.
This post is an end-to-end walkthrough of how to add theming in a real app. I'll also include sample code for a real project that you can download (or, you can just download it on GitHub right now if you just want to dive in).
Prerequisites ¶
Before you use this approach, there are two things to keep in mind.
- Your app must be targeting iOS 17 or later.
- You must be using a scene delegate.
So, please confirm you're good to go on these before going further. Things won't work otherwise!
Now that we've gotten that out of the way, let's get started.
Understanding Trait Collections ¶
Trait collections aren't really new in iOS; they've been around since iOS 8. So if you're an experienced iOS developer, you've probably had a few run-ins with this API.
But for those who might be newer, here's how the UIKit documentation defines a trait collection:
A collection of data that represents the environment for an individual element in your app’s user interface.
Cool. Let's read on.
The
traitCollection
property of theUITraitEnvironment
protocol contains traits that describe the state of various elements of the iOS user interface, such as size class, display scale, and layout direction. Together, these traits compose the UIKit trait environment.
Now this is starting to feel a bit less abstract. UITraitCollection
represents data about how things should look and behave, based on the specifics of the device itself and user preferences (dark mode is a good example).
One problem with UITraitCollection
is that traits are immutable. How can we allow users to change themes if that's the case? This is where the UIMutableTraits
protocol comes in (this is one of the key changes in iOS 17 that helps make this possible).
Here's what Apple says:
The
UIMutableTraits
protocol provides read-write access to get and set trait values on an underlying container. UIKit uses this protocol to facilitate working with instances ofUITraitCollection
, which are immutable and read-only. TheUITraitCollection
initializerinit(mutations:)
uses an instance ofUIMutableTraits
, which enables you to set a batch of trait values in one method call.UITraitOverrides
conforms toUIMutableTraits
, making it easy to set trait overrides on trait environments such as views and view controllers.
A little jargony, but this is saying that UIMutableTraits
lets us manipulate traits on demand (seems useful 🤔).
All we need to do is to comform our trait to UITraitDefinition
and then add getters and settings in a UIMutableTraits
extension.
Defining Your Theme ¶
We have all of the components we need, but how do you actually put it to use?
Let's start with a super simple theme that just has two variants (light and dark) and two properties (a foreground and background color).
We'll use use UIColor
's dynamic initializer (init(dynamicProvider: @escaping (UITraitCollection) -> UIColor)
) to define our colors. From the docs:
Use this method to create a color object whose component values change based on the currently active traits. The block you provide creates a new color object based on the traits in the provided trait collection.
Perfect. Just what we need.
Theme.swift
enum Theme: Int {
case light
case dark
var name: String {
switch self {
case .light: return "Light"
case .dark: return "Dark"
}
}
static var backgroundColor: UIColor {
return UIColor { traitCollection in
switch traitCollection.theme {
case .light: return .white
case .dark: return .black
}
}
}
static var foregroundColor: UIColor {
return UIColor { traitCollection in
switch traitCollection.theme {
case .light: return .black
case .dark: return .white
}
}
}
}
Now create a trait definition.
All traits contained in a
UITraitCollection
conform to this protocol. You can create custom traits by defining your own conforming type.
Theme.swift
(continued):
// ...
struct ThemeTrait: UITraitDefinition {
typealias Value = Theme
static let defaultValue = Theme.light
static let affectsColorAppearance = true
static var name: String = "theme"
static var identifier = "com.company.theme"
}
Finally, extend UITraitCollection
and add the theme to UIMutableTraits
.
Theme.swift
(continued):
// ...
extension UITraitCollection {
var theme: Theme { self[ThemeTrait.self] }
}
extension UIMutableTraits {
var theme: Theme {
get { self[ThemeTrait.self] }
set { self[ThemeTrait.self] = newValue }
}
}
Using Your Theme ¶
Now that we have our theme, we can now apply theme values to UI elements dynamically. It's really this simple:
view.backgroundColor = Theme.backgroundColor
But wait, you might have noticed we left something out: the default trait will clearly work, but how do we change it? To do that, we'll need to use trait overrides.
Trait Overrides ¶
A trait override is a way to change a mutable trait on a trait collection (this is the thing that was recently introduced in iOS 17, that makes this all possible). You'll probably want to do this on application startup and on theme updates.
To handle the trait override on application startup, it'll need to be included it in the scene(_:willConnectTo:options:)
method in the scene delegate (another reminder: the approach we're using requires scenes).
Here's what a full scene delegate implementation might look like:
SceneDelegate.swift
:
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
let rootViewController = NavigationController(rootViewController: ItemListViewController())
window.rootViewController = rootViewController
// Key line! Set the theme here ⬇️.
windowScene.traitOverrides.theme = ThemeStore.shared.getTheme()
self.window = window
window.makeKeyAndVisible()
}
}
And here's the corresponding configuration that stores the theme. You may already have an existing method of persisting defaults, so please don't use this code; it's just an illustration.
ThemeStore.swift
:
import UIKit
struct ThemeStore {
static let shared = ThemeStore()
private let userDefaults: UserDefaults
private let themeKey = "theme"
private init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
func setTheme(_ theme: Theme) {
userDefaults.set(theme.rawValue, forKey: themeKey)
}
func getTheme() -> Theme {
Theme(rawValue: userDefaults.integer(forKey: themeKey)) ?? .light
}
}
Lastly, here's how to change the theme dynamically. This can be done anywhere where the windowScene
is accessible, such as in a UIViewController
:
let oldTheme = ThemeStore.shared.getTheme()
let newTheme = oldTheme == .dark ? Theme.light : Theme.dark
guard let windowScene = view.window?.windowScene else { return }
// Update the current theme in the theme store and update the trait overrides.
ThemeStore.shared.setTheme(newTheme)
windowScene.traitOverrides.theme = newTheme
With this, the theme trait is updated immediately, and all views that depends on this trait will be immediately updated, like magic. ✨
Conclusion ¶
I've only scratched the surface on the theming possibilities with the UITraitCollection
APIs (heck, I didn't even mention SwiftUI!). But it should be enough to get you started.
If you want to keep going, here's a few other resources I'd recommend:
- Theming iOS Apps is No Longer Hard, by Shadowfacts
- Dark Mode, from the iOS Human Interface Guidelines
- Adopting iOS Dark Mode
- WWDC23: Unleash the UIKit trait system
- WWDC19: Implementing Dark Mode on iOS
Or, Just Ignore Everything I Just Wrote and Download the Sample Code ¶
Check out iOSTraitCollectionThemingExample on GitHub for an example app that demonstrates this technique in action.