What is a trait collection, and how do size classes and trait changes drive adaptive layout?
Predict first
What do the merged trait collection’s values show?
import UIKit
let a = UITraitCollection(horizontalSizeClass: .compact)
let b = UITraitCollection(userInterfaceStyle: .dark)
let m = UITraitCollection(traitsFrom: [a, b])
print(m.horizontalSizeClass.rawValue, m.userInterfaceStyle.rawValue, m.verticalSizeClass.rawValue)
- 1 2 0
- 1 1 1
- 2 2 2
Predict first
Does an override on a controller reach its view and children?
import UIKit
let parent = UIViewController()
let child = UIViewController()
parent.addChild(child)
parent.view.addSubview(child.view)
child.didMove(toParent: parent)
parent.overrideUserInterfaceStyle = .dark
print(parent.traitCollection.userInterfaceStyle.rawValue, parent.overrideUserInterfaceStyle.rawValue, child.overrideUserInterfaceStyle.rawValue)
- 2 2 0
- 1 1 0
- 2 1 2
Mental model. Traits are the weather report your view receives: it does not ask which city it is in, just whether it is raining, and dresses accordingly.
A UITraitCollection describes the environment a view or controller is in: horizontalSizeClass and verticalSizeClass (compact or regular), userInterfaceStyle (light or dark), preferredContentSizeCategory (Dynamic Type), displayScale, layoutDirection, userInterfaceIdiom and more. Traits flow down the hierarchy from the screen through the window, controllers and views, and a parent can override them for its children, for example overrideUserInterfaceStyle = .dark forces a subtree dark. Unspecified traits are merged from the parent, so a collection can be partly set.
Size classes are deliberately coarse. An iPhone in portrait is compact width and regular height, in landscape most are compact in both, and an iPad is regular in both until multitasking shrinks it to compact width. Branch on size classes, never on device model, because a split-screen iPad app must behave like a phone. React to changes with registerForTraitChanges(_:handler:) (iOS 17), which replaced overriding traitCollectionDidChange, naming the specific traits you care about so the callback fires only for those. Use the closure-based sizing in compositional layout (environment.traitCollection), or switch constraint sets with NSLayoutConstraint.activate/deactivate when the class changes. Dynamic Type is a trait too: size changes need layouts that grow, not clip.
Compact is 1, dark is 2, and the vertical class was never specified, so it stays 0 (unspecified).
The override is set on the controller, and its trait collection reports dark (2) for both. The child has no override of its own (0, unspecified); it inherits the effective style from the hierarchy once it is in a window.
Common traps
- Branching on
UIDevice.current.userInterfaceIdiomor screen size. Split View and Stage Manager break it. Use size classes. - Doing work in a trait callback without naming the trait. It then fires for every change. Register for the specific ones.
They might follow up with
- How do you force a subtree dark? Set
overrideUserInterfaceStyle = .darkon a view or controller. - What replaced
traitCollectionDidChange?registerForTraitChanges, introduced in iOS 17.
Related questions
- 7.7.2 What is the safe area, and how do you keep content clear of the notch, home indicator and keyboard?
- 7.7.3 How do dynamic colours, dark mode and the UIAppearance proxy work?
- 7.5.1 How does compositional layout describe a collection view, and how do you read its output?
What is the safe area, and how do you keep content clear of the… →