Predicting Size Classes in iOS 13

If you rely on size class changes to build adaptive layouts, you should review your code for iOS 13. UIKit now predicts the initial traits for a view so you cannot assume traitCollectionDidChange will be called when a view is first added to the view hierarchy.

Trait Collections and Size Classes

Trait collections were first introduced by Apple back in iOS 8 to describe the environmental properties (traits) of a user interface. The horizontal and vertical size class traits are a crude indication whether the width or height is constrained (compact) or not (regular). See Size Class Reference Guide for the details.

The idea is that you build layout variations to adapt to compact and regular size classes. You can add trait variations directly in Interface Builder, or if you prefer, using the following methods:

  • traitCollectionDidChange (view controller or view)
  • willTransition(to:with:) (view controller)

For an example, see Adapting Auto Layout Without Interface Builder where I have different sets of constraints for compact and regular size widths. I switch between the constraints based on the horizontal size class:

private func enableConstraintsForWidth(_ horizontalSizeClass: UIUserInterfaceSizeClass) {
  if horizontalSizeClass == .regular {
    NSLayoutConstraint.deactivate(compactConstraints)
    NSLayoutConstraint.activate(regularConstraints)
  } else {
    NSLayoutConstraint.deactivate(regularConstraints)
    NSLayoutConstraint.activate(compactConstraints)
  }
}

The switch happens in traitCollectionDidChange:

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

In iOS 12 and earlier, UIKit calls the traitCollectionDidChange method when adding the view to the view hierarchy. The previousTraitCollection is nil the first time. The current value is available in the traitCollection property of the view controller. I rely on this method being called when the view is first added to enable the correct set of initial constraints.

What Changed in iOS 13

In iOS 13, UIKit sets the traits of a view when you create it before you add the view to the view hierarchy. UIKit guesses the likely traits for the view based on the context. Then when you add the view to the view hierarchy, the actual traits are inherited from the parent.

If UIKit guesses correctly the trait collection for a view doesn’t change when adding the view to the view hierarchy. You no longer get the call to traitCollectionDidChange which is a problem if you rely on it to set the initial state of your layout.

One possible workaround is to do it from viewDidLoad:

override func viewDidLoad() {
  super.viewDidLoad()
  ...
  enableConstraintsForWidth(traitCollection.horizontalSizeClass)
}

In iOS 12 and earlier, there is no promise from Apple that UIKit has correctly set the traits for the view at this point. However, we should get a call to traitCollectionDidChange when the view is added to the hierarchy so we can correct things.

In iOS 13, this sets the initial state of the layout based on the predicted traits. If UIKit guesses wrong, we get the call to traitCollectionDidChange with the final size class. Either way, we end up with a correctly configured layout.

Layout Subviews

An alternative that Apple suggests is to perform any work involving traits in one of the layout methods:

  • UIViewController.viewWillLayoutSubviews()
  • UIView.layoutSubviews()
  • UIViewController.viewDidLayoutSubviews()

The trait collections are updated before layout occurs, so are current in any of the above methods. The only caveat with this approach is that the layout methods may be called multiple times during the life of the view controller or view so you should take steps to avoid repeating work:

private var firstTime = true
override func viewWillLayoutSubviews() {
  super.viewWillLayoutSubviews()
  if firstTime {
    firstTime = false
    enableConstraintsForWidth(traitCollection.horizontalSizeClass)
  }
}

We set the layout state the first time layout is performed and then rely on traitCollectionDidChange for any future size class changes.

What Should I Do?

Review your code for any use of traitCollectionDidChange. If you rely on it being called to set the initial layout of your views call your setup code from one of the layout subview methods or from viewDidLoad.

If you only use trait variations in Interface Builder you have nothing to do.

Debugging Trait Collection Changes

A final tip. You can log the previous and current trait collections every time they change by adding a launch argument to the scheme:

-UITraitCollectionChangeLoggingEnabled YES

Don’t forget the leading “-”:

Launch argument -UITraitCollectionChangeLoggingEnabled YES

Each time traitCollectionDidChange is called it logs the changes, the previous and current trait collections:

[TraitCollectionChange] Sending -traitCollectionDidChange: to ...
  ► trait changes: { HorizontalSizeClass: Unspecified → Regular;
  VerticalSizeClass: Unspecified → Regular }
  ► previous: <UITraitCollection: 0x600003f2c6c0; DisplayScale = 1, ...
  ► current: <UITraitCollection: 0x600003f14540; DisplayScale = 1, ...

Read More

Apple describes the changes in the last part of the WWDC session on dark mode:

If you’re still maintaining UIKit projects and you can’t remember what any of this means anymore you might find my Auto Layout book useful.