Custom Traits and SwiftUI

Starting with iOS 17 you can add your own custom traits to the UIKit trait environment and have them interoperate with the SwiftUI environment.

Trait Changes in iOS 17

Apple added the UITraitEnvironment protocol way back in iOS 8. It adds a traitCollection property and traitCollectionDidChange method to conforming types including UIScreen, UIWindowScene, UIView and UIViewController.

In iOS 17, Apple made significant changes to the handling of traits and deprecated some earlier trait related methods. See registering for trait changes for the new way to observe trait changes that deprecates traitCollectionDidChange.

Starting in iOS 17, we can add our own custom traits to a trait collection. Even better you can bridge them to the SwiftUI environment for any hosted SwiftUI views.

Custom traits are well suited for passing simple value types down many levels of the view hierarchy. Apple recommends against using classes as trait values. Since the system also compares trait values often you should make sure that checking your trait type for equality is fast.

Creating A Custom Trait

If you’ve created SwiftUI custom environment values you may notice that creating a custom UIKit trait is familiar. For example, let’s create a custom trait for a boolean feature flag:

  1. Create the trait definition. All traits contained in a UITraitCollection conform to the UITraitDefinition protocol. We create a new trait by defining our own conforming type. Like a SwiftUI environment key we need to give it a default value:

    struct FeatureOneTrait: UITraitDefinition {
      static let defaultValue = false
    }
    
  2. Extend the trait collection class to include a getter for our custom attribute:

    extension UITraitCollection {
      var featureOne: Bool { self[FeatureOneTrait.self] }
    }
    
  3. Extend the mutable traits protocol, new in iOS 17, to allow reading and modifying the custom trait:

    extension UIMutableTraits {
      var featureOne: Bool {
         get { self[FeatureOneTrait.self] }
         set { self[FeatureOneTrait.self] = newValue }
      }
    }
    

When conforming the custom trait to UITraitDefinition the only requirement is to provide a default value but there are some optional steps:

  1. If you’re creating a trait that affects the color appearance of a view you should set the affectsColorAppearance property to true. This causes the system to redraw views when the trait changes. For example, if I had a trait for the theme of my app:

    struct ThemeTrait: UITraitDefinition {
      static let defaultValue = Theme.light
      static let affectsColorAppearance = true
    }
    
  2. Traits have a name that as far as I can tell is only used for debugging. It defaults to the name of the type but you can change it:

    struct FeatureOneTrait: UITraitDefinition {
      static let defaultValue = false
      static let name = "FeatureOne"
    }
    
  3. You can optionally set an identifier for a trait. That becomes useful if you want to encode or persist the trait. Apple recommends you use reverse-DNS format to make the identifier globally unique in the app:

    struct FeatureOneTrait: UITraitDefinition {
      static let defaultValue = false
      static let identifer = "com.useyourloaf.myapp.featureOne"
    }
    

Overriding A Custom Trait

To give your custom trait a value other than its default you need to apply a trait override somewhere in the view hierarchy. The way you override traits also changed in iOS 17 to use the new traitOverrides property.

The traitCollection property of a UITraitEnvironment type is read-only. To apply an override you set the traitOverridesproperty of a UIWindowScene, UIWindow, UIViewController, UIPresentationController, or UIView:

windowScene.traitOverrides.featureOne = true

Note : The UIScreen class, which does conform to UITraitEnvironment does not support the new trait overrides mechanism for custom traits. The window scene is the highest point in the view hierarchy where you can override traits.

Trait environments flow down the view hierarchy from the window scene, window, parent view controller, views and on down into any child view controllers.

Apple made a subtle change to the way you inherit traits in iOS 17. Before iOS 17, child view controllers inherited their traits from the parent view controller, ignoring any traits of the views of the parent. In iOS 17, traits flow down through view controllers and views so a child view controller inherits traits from its superview in the parent view controller.

A Practical Example

Let’s look at a practical example where I want to apply my feature flag trait override at the window scene. I’m saving and restoring the value of my trait to user defaults, using the trait identifier as the key. In my scene delegate, when the scene first connects to its window I read the value of the trait from user defaults and apply the trait override to the window scene:

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
  var window: UIWindow?
    
  func scene(_ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions) {
    guard let scene = scene as? UIWindowScene else {
      return
    }

    let defaults = UserDefaults.standard
    let featureOne = defaults.bool(forKey: FeatureOneTrait.identifier)
    scene.traitOverrides.featureOne = featureOne

I also want to register for trait changes so that I can save my trait back to user defaults if it changes (see registering for trait changes):

    scene.registerForTraitChanges([FeatureOneTrait.self]) {
      (windowScene: UIWindowScene,
       previousTraitCollection: UITraitCollection) in
         defaults.setValue(windowScene.traitCollection.featureOne,
           forKey: FeatureOneTrait.identifer)
    }
  }
}

My custom trait is now available to any view controllers or views in the view hierarchy of the window scene in their traitCollection property:

traitCollection.featureOne

For example, if I have view controller that depends on the feature trait. I can have it register for changes to the trait on the view controllers trait collection and update as needed:

final class FeatureViewController: UIViewController {
  @IBOutlet private weak var featureLabel: UILabel!

  override func viewDidLoad() {
    super.viewDidLoad()
    registerForTraitChanges([FeatureOneTrait.self]) { (self: Self,
      previousTraitCollection: UITraitCollection) in
      self.enableFeature(self.traitCollection.featureOne)
    }
  }

  override func viewIsAppearing(_ animated: Bool) {
    super.viewIsAppearing(animated)
    enableFeature(traitCollection.featureOne)
  }

  private func enableFeature(_ enabled: Bool) {
    featureLabel.text = enabled ? "Enabled" : 
                                  "Disabled"
  }
}

Note the use of viewIsAppearing to set the initial state of the feature.

If you want to change the value of the trait override applied to the window scene you can get the scene from the window of any view. For example, a method in a view controller that enables/disables the feature:

@IBAction private func featureOneChanged(_ sender: UISwitch) {
  if let windowScene = view.window?.windowScene {
    windowScene.traitOverrides.featureOne = sender.isOn
  }
}

Handling Multiple Windows

Apple don’t provide any guidance on how to manage custom traits for situations like an iPad app that has multiple windows. In my testing, I’ve found it best to have the scene delegate manage any app-wide custom traits, applying the trait override at the window scene level.

In an iPad app with multiple windows any of the scenes could potentially change the value of my custom trait. My other scenes need to observe that change and update their trait override. In my example, I’m persisting the trait to user defaults so I added an observer for changes to the user defaults database:

In my scene delegate:

NotificationCenter.default.addObserver(self,
  selector: #selector(defaultsDidChange),
  name: UserDefaults.didChangeNotification,
  object: nil)

Then when the notification arrives I override the trait for the scene to keep each scene in sync:

@objc
private func defaultsDidChange() {
  guard let scene = window?.windowScene else {
    return
  }
    
  let defaults = UserDefaults.standard
  let featureOne = defaults.bool(forKey: FeatureOneTrait.identifer)
  if scene.traitOverrides.featureOne != featureOne {
    scene.traitOverrides.featureOne = featureOne
  }
}

Bridging Traits and the SwiftUI Environment

I’m assuming the reason Apple made these improvements to the UIKit trait environment is to improve interoperability with SwiftUI. This reminds me a lot of the Objective-C improvements we saw to improve interoperability with Swift.

To bridge our custom trait to the SwiftUI environment we start by creating the corresponding custom SwiftUI environment key (see SwiftUI custom environment values) and then add conformance to UITraitBridgedEnvironmentKey:

  1. Create the SwiftUI environment key with a default value:

    struct FeatureOneKey: EnvironmentKey {
      static let defaultValue = false
    }
    
  2. Extend the environment with a getter/setter for our key:

    extension EnvironmentValues {
      var featureOne: Bool {
        get { self[FeatureOneKey.self] }
        set { self[FeatureOneKey.self] = newValue }
      }
    }
    
  3. Bridge to the UIKit trait by adding conformance to UITraitBridgedEnvironmentKey. This requires a read method that returns the value of the UIKit trait and a write method to set the value of the UIKit mutable trait:

    extension FeatureOneKey: UITraitBridgedEnvironmentKey {
      static func read(from traitCollection: UITraitCollection) -> Bool {
        traitCollection.featureOne
      }
    
      static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
        mutableTraits.featureOne = value
      }
    }
    

Any SwiftUI view in our view hierarchy can now access the UIKit trait using the SwiftUI environment. For example, here’s a feature view that reads the trait from the environment:

import SwiftUI 

struct FeatureView: View {
  @Environment(\.featureOne) private var featureOne
  var body: some View {
    Image(systemName: featureOne ? "checkmark.seal.fill" :
                                    "xmark.seal.fill")
      .resizable()
      .aspectRatio(1, contentMode: .fit)
      .foregroundStyle(.blue)
  }
}

I created a hosting view controller for the SwiftUI view that I added as child view controller of my feature view controller:

final class HostedFeatureController: UIHostingController<FeatureView> {
  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder, rootView: FeatureView())
  }
}

Storyboard showing view controller with embedded child hosted feature controller

My SwiftUI feature view now updates to any changes made to the UIKit trait override I set in the window scene:

Device screenshot showing feature one enabled and blue checkmark

Summary

Here’s the final custom trait definition for reference:

// FeatureTrait.swift
import SwiftUI
import UIKit

struct FeatureOneTrait: UITraitDefinition {
  static let defaultValue = false
  static let identifer = "com.useyourloaf.myapp.featureOne"
}

extension UITraitCollection {
   var featureOne: Bool { self[FeatureOneTrait.self] }
}

extension UIMutableTraits {
  var featureOne: Bool {
    get { self[FeatureOneTrait.self] }
    set { self[FeatureOneTrait.self] = newValue }
  }
}

struct FeatureOneKey: EnvironmentKey {
  static let defaultValue = false
}

extension EnvironmentValues {
  var featureOne: Bool {
    get { self[FeatureOneKey.self] }
    set { self[FeatureOneKey.self] = newValue }
  }
}

extension FeatureOneKey: UITraitBridgedEnvironmentKey {
  static func read(from traitCollection: UITraitCollection) -> Bool {
    traitCollection.featureOne
  }

  static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
    mutableTraits.featureOne = value
  }
}

Learn More