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 the UITraitEnvironment 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 of UITraitCollection, which are immutable and read-only. The UITraitCollection initializer init(mutations:) uses an instance of UIMutableTraits, which enables you to set a batch of trait values in one method call. UITraitOverrides conforms to UIMutableTraits, 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:

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.