WWDC Quick Look 💓 By SwiftGGTeam
Unleash the UIKit trait system

Unleash the UIKit trait system

Watch original video

Highlight

iOS 17’s UIKit Trait System adds custom trait definitions, trait override APIs, closure-based trait change observation, and two-way bridging with SwiftUI environment keys—letting data flow seamlessly between UIKit and SwiftUI components.

Core Content

Trait basics recap

Traits are independent pieces of data the system propagates to every view controller and view in your app. UIKit includes many system traits: user interface style, horizontal size class, preferred content size category, and more.

iOS 17 unifies the trait hierarchy for view controllers and views. Previously, a view controller’s traits inherited directly from its parent controller, and views inherited from their owning controller—causing trait propagation to “break” at the controller’s view in the view hierarchy. Now propagation is linear: view controllers inherit traits from their view’s superview, and traits flow naturally through the view hierarchy.

An important consequence: accessing the trait collection in viewWillAppear may return stale values because the view hasn’t joined the hierarchy yet. viewIsAppearing is a better alternative and is back-deployable to iOS 13.

Custom traits

iOS 17 lets you define your own traits via the UITraitDefinition protocol. This opens a new data propagation pattern—no delegates, no closures, no singletons; data automatically propagates to deeply nested components.

When defining traits, prefer value types for associated data. Bool, Int, Double, or Int raw value enums are most efficient. Custom structs need an efficient Equatable implementation because the system frequently compares trait values to detect changes.

If a trait affects color resolution, set affectsColorAppearance = true so the system automatically redraws views when the trait changes. This has higher overhead—use only for infrequently changing traits.

Trait overrides

iOS 17 adds a traitOverrides property on every trait environment (window scene, window, view controller, view, presentation controller). Overrides modify trait values for that object and all its descendants.

traitOverrides supports checking whether an override exists (contains) and removing overrides (remove). Always read trait values from traitCollection—reading traitOverrides directly throws when not set.

Performance: each override has a small cost; set them only where needed. When traits change, the system updates trait collections for all descendants—minimize how often you modify overrides. If a trait affects only a few deep views, apply the override to the nearest common ancestor rather than the root.

Observing trait changes

traitCollectionDidChange is deprecated in iOS 17. The problem: the system doesn’t know which traits you care about, so any trait change triggers a callback—not scalable.

The new registerForTraitChanges API supports closure and target-action callbacks. Register only the traits you care about; callbacks fire only when those traits change. No subclassing required—you can observe from anywhere.

Semantic system trait sets are also provided: systemTraitsAffectingColorAppearance, systemTraitsAffectingImageLookup—pass them directly to the registration method.

Registration cleans up automatically; advanced scenarios can manually unregister using the returned token.

UIKit and SwiftUI bridging

Custom UIKit traits and custom SwiftUI environment keys can be bridged. Make your environment key conform to UITraitBridgedEnvironmentKey and implement read and write methods. UIKit trait overrides then sync automatically to SwiftUI’s environment, and SwiftUI environment modifiers sync to UIKit traits.

This solves data propagation pain in mixed UI architectures. Whether SwiftUI is embedded in UIKit or UIKit in SwiftUI, data flows both ways.

Detailed Content

Creating and modifying trait collections

01:51

// Build a new trait collection instance from scratch
let myTraits = UITraitCollection { mutableTraits in
    mutableTraits.userInterfaceIdiom = .phone
    mutableTraits.horizontalSizeClass = .regular
}

// Get a new instance by modifying traits of an existing one
let otherTraits = myTraits.modifyingTraits { mutableTraits in
    mutableTraits.horizontalSizeClass = .compact
    mutableTraits.userInterfaceStyle = .dark
}

Key points:

  • UITraitCollection gains a closure initializer
  • modifyingTraits creates a modified copy from an existing trait collection
  • UIMutableTraits is the new mutable container protocol

Defining a simple custom trait

09:06

struct ContainedInSettingsTrait: UITraitDefinition {
    static let defaultValue = false
}

let traitCollection = UITraitCollection { mutableTraits in
    mutableTraits[ContainedInSettingsTrait.self] = true
}

let value = traitCollection[ContainedInSettingsTrait.self]
// true

Key points:

  • Conform to UITraitDefinition
  • Must provide defaultValue
  • Value type is inferred from defaultValue (Bool here)

Adding property syntax for custom traits

10:23

struct ContainedInSettingsTrait: UITraitDefinition {
    static let defaultValue = false
}

extension UITraitCollection {
    var isContainedInSettings: Bool { self[ContainedInSettingsTrait.self] }
}

extension UIMutableTraits {
    var isContainedInSettings: Bool {
        get { self[ContainedInSettingsTrait.self] }
        set { self[ContainedInSettingsTrait.self] = newValue }
    }
}

let traitCollection = UITraitCollection { mutableTraits in
    mutableTraits.isContainedInSettings = true
}

let value = traitCollection.isContainedInSettings
// true

Key points:

  • Add a read-only property to UITraitCollection
  • Add a read-write property to UIMutableTraits
  • Always write both extensions when defining a custom trait

Defining a theme trait that affects color appearance

11:00

enum MyAppTheme: Int {
    case standard, pastel, bold, monochrome
}

struct MyAppThemeTrait: UITraitDefinition {
    static let defaultValue = MyAppTheme.standard
    static let affectsColorAppearance = true
    static let name = "Theme"
    static let identifier = "com.myapp.theme"
}

extension UITraitCollection {
    var myAppTheme: MyAppTheme { self[MyAppThemeTrait.self] }
}

extension UIMutableTraits {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeTrait.self] }
        set { self[MyAppThemeTrait.self] = newValue }
    }
}

Key points:

  • affectsColorAppearance = true triggers automatic redraw when the trait changes
  • name is used for debug output
  • identifier uses reverse-DNS format; supports encoding and other features
  • Int raw value enums are most efficient

Using a custom theme trait

12:33

let customBackgroundColor = UIColor { traitCollection in
    switch traitCollection.myAppTheme {
    case .standard:    return UIColor(named: "StandardBackground")!
    case .pastel:      return UIColor(named: "PastelBackground")!
    case .bold:        return UIColor(named: "BoldBackground")!
    case .monochrome:  return UIColor(named: "MonochromeBackground")!
    }
}

let view = UIView()
view.backgroundColor = customBackgroundColor

Key points:

  • Read custom traits in dynamic color provider closures
  • With affectsColorAppearance = true, views update automatically when the theme changes
  • No manual change observation or refresh needed

Managing trait overrides

18:05

func toggleThemeOverride(_ overrideTheme: MyAppTheme) {
    if view.traitOverrides.contains(MyAppThemeTrait.self) {
        // There's an existing theme override; remove it
        view.traitOverrides.remove(MyAppThemeTrait.self)
    } else {
        // There's no existing theme override; apply one
        view.traitOverrides.myAppTheme = overrideTheme
    }
}

Key points:

  • contains checks whether an override exists
  • remove removes the override and restores the inherited value
  • Assign directly to set an override

Registering for trait changes with a closure

21:28

// Register for horizontal size class changes on self
registerForTraitChanges(
    [UITraitHorizontalSizeClass.self]
) { (self: Self, previousTraitCollection: UITraitCollection) in
    self.updateViews(sizeClass: self.traitCollection.horizontalSizeClass)
}

// Register for changes to multiple traits on another view
let anotherView: MyView
anotherView.registerForTraitChanges(
    [UITraitHorizontalSizeClass.self, ContainedInSettingsTrait.self]
) { (view: MyView, previousTraitCollection: UITraitCollection) in
    // Handle the trait change for this view...
}

Key points:

  • Register only traits you actually depend on to avoid irrelevant callbacks
  • The closure’s first parameter is the observed object—no weak self needed
  • When observing self, write (self: Self, ...)

Registering for trait changes with target-action

22:48

// Register for horizontal size class changes on self
registerForTraitChanges(
    [UITraitHorizontalSizeClass.self],
    action: #selector(UIView.setNeedsLayout)
)

// Register for changes to multiple traits on another view
let anotherView: MyView
anotherView.registerForTraitChanges(
    [UITraitHorizontalSizeClass.self, ContainedInSettingsTrait.self],
    target: self,
    action: #selector(handleTraitChange(view:previousTraitCollection:))
)

@objc func handleTraitChange(view: MyView, previousTraitCollection: UITraitCollection) {
    // Handle the trait change for this view...
}

Key points:

  • Action methods can have 0, 1, or 2 parameters
  • First parameter is the changed object; second is the previous trait collection
  • Target defaults to the registration caller when omitted

Bridging UIKit traits and SwiftUI environment keys

26:19

enum MyAppTheme: Int {
    case standard, pastel, bold, monochrome
}

// Custom UIKit trait
struct MyAppThemeTrait: UITraitDefinition {
    static let defaultValue = MyAppTheme.standard
    static let affectsColorAppearance = true
}

extension UITraitCollection {
    var myAppTheme: MyAppTheme { self[MyAppThemeTrait.self] }
}

extension UIMutableTraits {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeTrait.self] }
        set { self[MyAppThemeTrait.self] = newValue }
    }
}

// Custom SwiftUI environment key
struct MyAppThemeKey: EnvironmentKey {
    static let defaultValue = MyAppTheme.standard
}

extension EnvironmentValues {
    var myAppTheme: MyAppTheme {
        get { self[MyAppThemeKey.self] }
        set { self[MyAppThemeKey.self] = newValue }
    }
}

// Bridge SwiftUI environment key with UIKit trait
extension MyAppThemeKey: UITraitBridgedEnvironmentKey {
    static func read(from traitCollection: UITraitCollection) -> MyAppTheme {
        traitCollection.myAppTheme
    }

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

Key points:

  • Make EnvironmentKey conform to UITraitBridgedEnvironmentKey
  • read reads from UIKit traits and returns to SwiftUI
  • write writes SwiftUI environment values into UIKit traits
  • Both sides access the same data

Setting traits from UIKit, reading in SwiftUI

27:01

// UIKit trait override applied to the window scene
let windowScene: UIWindowScene
windowScene.traitOverrides.myAppTheme = .monochrome

// Cell in a UICollectionView configured to display a SwiftUI view
let cell: UICollectionViewCell
cell.contentConfiguration = UIHostingConfiguration {
    CellView()
}

// SwiftUI view displayed in the cell, which reads the bridged value from the environment
struct CellView: View {
    @Environment(\.myAppTheme) var theme: MyAppTheme

    var body: some View {
        Text("Settings")
            .foregroundStyle(theme == .monochrome ? .gray : .blue)
    }
}

Key points:

  • Window scene trait overrides propagate automatically to SwiftUI environment
  • SwiftUI reads bridged values with @Environment
  • SwiftUI automatically tracks dependencies; views update when traits change

Setting environment from SwiftUI, reading in UIKit

28:16

// SwiftUI environment value applied to a UIViewControllerRepresentable
struct SettingsView: View {
    var body: some View {
        SettingsControllerRepresentable()
            .environment(\.myAppTheme, .standard)
    }
}

final class SettingsControllerRepresentable: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> SettingsViewController {
        SettingsViewController()
    }
    
    func updateUIViewController(_ uiViewController: SettingsViewController, context: Context) {
        // Update the view controller...
    }
}

// UIKit view controller contained in the SettingsControllerRepresentable
class SettingsViewController: UIViewController {
    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()
        title = settingsTitle(for: traitCollection.myAppTheme)
    }
    
    func settingsTitle(for theme: MyAppTheme) -> String {
        switch theme {
        case .standard:   return "Standard"
        case .pastel:     return "Pastel"
        case .bold:       return "Bold"
        case .monochrome: return "Monochrome"
        }
    }
}

Key points:

  • SwiftUI’s .environment modifier is equivalent to UIKit trait overrides
  • UIKit code reads bridged values with traitCollection.myAppTheme
  • Two-way bridging—data flows seamlessly between frameworks

Core Takeaways

1. Replace deep data passing with custom traits

  • What to build: Change context passed via delegates, closures, or NotificationCenter into custom traits
  • Why it’s worth doing: Traits propagate along the hierarchy automatically; deeply nested components read directly—cleaner code
  • How to start: Define a UITraitDefinition, set traitOverrides on a common ancestor, read from traitCollection where needed

2. Add multi-theme support to your app

  • What to build: Use a custom theme trait for in-app theme switching (standard, pastel, bold, monochrome, etc.)
  • Why it’s worth doing: Dynamic colors redraw automatically when traits change—no manual view refresh
  • How to start: Define MyAppThemeTrait, set affectsColorAppearance = true, create dynamic colors with UIColor { traitCollection in ... }

3. Migrate traitCollectionDidChange to registerForTraitChanges

  • What to build: Replace traitCollectionDidChange overrides with registerForTraitChanges
  • Why it’s worth doing: Observe only traits you care about, reducing unnecessary callbacks and improving performance
  • How to start: Find traitCollectionDidChange implementations and replace with registerForTraitChanges([traits you care about]) { ... }

4. Bridge data in mixed UIKit/SwiftUI projects

  • What to build: Make shared data a bridged trait/environment key
  • Why it’s worth doing: No extra sync logic—setting on one side automatically updates the other
  • How to start: Define a UIKit trait and SwiftUI EnvironmentKey; make the key conform to UITraitBridgedEnvironmentKey

5. Use traitOverrides for localized UI adjustments

  • What to build: Override traits on specific view controllers or views for local size class, theme, or style adjustments
  • Why it’s worth doing: More declarative than manual state passing; descendant views inherit automatically
  • How to start: Add view.traitOverrides.xxx = value on the view that needs adjustment

Comments

GitHub Issues · utterances