Registering For Trait Changes

Apple deprecated traitCollectionDidChange in iOS 17, replacing it with a method to register for specific trait changes. Let’s see how that works.

The UITraitEnvironment Protocol

Apple first introduced the UITraitEnvironment protocol back in iOS 8. It provides a traitCollection property and the traitCollectionDidChange method:

// UITraitEnvironment
var traitCollection: UITraitCollection { get }
func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)

It’s adopted by UIPresentationController, UIScreen, UIView, UIViewController and UIWindowScene. The UITraitCollection class is collection of environment properties (traits) for the user interface. This includes things like the user interface idiom (phone, pad, tv, etc.), user interface style (light, dark), preferred content size for dynamic type and the horizontal and vertical size class (compact or regular).

If you’re building an adaptive layout that works well across different devices, window sizes and content size you’ll want to respond to trait changes.

Prior to iOS 17, I would do that in traitCollectionDidChange. In this case, switching a stack view from a horizontal to vertical layout when the horizontal size class changes to compact:

final class ViewController: UIViewController {
  private lazy var stackView: UIStackView = { ... }

  override func viewDidLoad() {
    super.viewDidLoad()
    ...
    configureView()
  }

  override func traitCollectionDidChange(_ previousTraitCollection:
    UITraitCollection?) {
    super.traitCollectionDidChange(previousTraitCollection)
    if previousTraitCollection?.horizontalSizeClass !=
      traitCollection.horizontalSizeClass {
      configureView()
    }
  }

  private func configureView() {
    if traitCollection.horizontalSizeClass == .compact {
      stackView.axis = .vertical
    } else {
      stackView.axis = .horizontal
    }
  }
}

Notes:

  • The system calls traitCollectionDidChange for every trait change, and there are a lot of traits. You need to check if the trait you want has changed before making any layout changes.
  • Starting with iOS 13, there’s no guarantee the system will call traitCollectionDidChange when the view controller’s view is first added to the view hierarchy. You need to set the initial stack view state from somewhere like viewDidLoad (see predicting size classes in iOS 13).

registerForTraitChanges (iOS 17)

In iOS 17, Apple deprecated traitCollectionDidChange. Instead the UITraitChangeObservable protocol provides methods to register a handler for the specific trait changes you’re observing. There are both target-action and closure based versions. First the target-action variant:

override func viewDidLoad() {
  super.viewDidLoad()
  ... 
  registerForTraitChanges([UITraitHorizontalSizeClass.self],
    action: #selector(configureView))
}

@objc
private func configureView() { ... }

The closure based version provides the observed object as the first parameter of the closure. In my case, I’m registering the view controller to observe its own traits:

override func viewDidLoad() {
  super.viewDidLoad()
  ... 
  registerForTraitChanges([UITraitHorizontalSizeClass.self]) { 
    (self: Self, previousTraitCollection: UITraitCollection) in
    self.configureView()
  }
}

private func configureView() { ... }

Notes:

  • When registering for trait changes on self, Apple recommends using self: Self as the first parameter of the closure to avoid capturing self.
  • Since my registered handler is only called if the horizontal size class changes there’s no need to check the previous trait collection before updating the layout.
  • You can register different handlers for different collections of traits.
  • Both methods return an opaque token you can use to stop observing trait changes but there’s no need to unregister your observations.

The registered trait handler is not called when the view is first loaded so you’ll still want to set the initial layout state. If you’re building against the iOS 17 SDK the new viewIsAppearing method is a great place for that. It’s supported back to iOS 13 and the system calls it after setting the traits (see UIKit View Lifecycle - viewIsAppearing):

override func viewIsAppearing(_ animated: Bool) {
  super.viewIsAppearing(animated)
  configureView()
}

Learn More