VButko

How to Use Custom Trait Collections in SwiftUI and UIKit

iOS 17 introduced a powerful new API for creating custom trait collections that we can use to propagate information to the view hierarchy in both SwiftUI and UIKit. In this article, we'll explore how to implement custom traits using a practical example: creating a content density setting that users can control. Custom traits are useful in many scenarios like theme variants, feature flags, or user interface preferences. They provide a clean, type-safe way to propagate configuration through your app's view hierarchy in both UIKit and SwiftUI.

Task Flow App Icon

Task Flow: Task Manager App

Task Flow offers innovative features like multi-project tasks and the 'My Task Flow' concept. On top of the project structure, you get an endlessly flexible Task Flow view that suits the way you think and work. Available on iPhone, iPad, Mac and Apple Vision.

Try it now!

What Are Custom Trait Collections?

Trait collections in UIKit represent the environment and context in which your interface runs. They include information like display scale, user interface style (light/dark), size classes, and more. With iOS 17, Apple introduced the ability to create custom traits that integrate seamlessly with the existing trait system.

Custom traits offer several advantages over traditional approaches like UserDefaults:

  • Automatic propagation through the view hierarchy
  • Built-in change notifications when trait values update
  • Integration with SwiftUI environment system
  • Type safety with compile-time checking

Creating a Content Density Trait

Let's build a practical example where users can choose between default and compact content density. This is particularly useful on macOS, where productive users often prefer seeing more information in less space.

First, we need to define an enum for our density options:

enum UIDensity: String, CaseIterable, Identifiable {
    case `default`
    case compact

    var id: String { self.rawValue }
}

Now let's create a trait and add it to the UITraitCollection:

struct UIDensityTrait: UITraitDefinition {
    static let defaultValue = UIDensity.default
}

extension UIMutableTraits {
    var uiDensity: UIDensity {
        get { self[UIDensityTrait.self] }
        set { self[UIDensityTrait.self] = newValue }
    }
}

extension UITraitCollection {
    var uiDensity: UIDensity {
        self[UIDensityTrait.self]
    }
}

We need both extensions because they serve different purposes:

  • The UIMutableTraits extension allows us to set trait values when configuring the trait hierarchy
  • The UITraitCollection extension allows us to read trait values in our views and view controllers

The defaultValue property will be used when no specific density is set in the trait hierarchy.

Now we can add some properties to our UIDensity enum. Let's say we want to change the margins and spacing based on the selected density:

extension UIDensity {
    var cellMargins: NSDirectionalEdgeInsets {
        switch self {
        case .default:
            return NSDirectionalEdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
        case .compact:
            return NSDirectionalEdgeInsets(top: 8, leading: 14, bottom: 8, trailing: 14)
        }
    }

    var cellSpacing: CGFloat {
        switch self {
        case .default: 14
        case .compact: 8
        }
    }
}

Using Custom Traits in UIKit

To react to trait changes in UIKit, the modern approach is to use registerForTraitChanges. This method is available on both UIViewController and UIView, and it's more efficient than overriding traitCollectionDidChange because it allows you to observe changes only for the specific traits you care about.

You can register for changes in your view controller's viewDidLoad method. The system calls your handler once upon registration to set the initial state, and then again whenever the specified trait changes.

class TaskListViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        registerForTraitChanges([UIDensityTrait.self]) { (self: Self, previousTraitCollection: UITraitCollection) in
            self.updateLayoutForCurrentDensity()
        }
    }

    private func updateLayoutForCurrentDensity() {
        let margins = traitCollection.uiDensity.cellMargins
        collectionView.contentInset = margins
    }
}

Using Custom Traits in SwiftUI

For SwiftUI apps, you need to bridge the custom trait to SwiftUI's environment system and set it at the top level of your app. This ensures all views in your app can access the density setting.

First, create the environment bridge:

extension EnvironmentValues {
    var uiDensity: UIDensity {
        get { self[UIDensityEnvironmentKey.self] }
        set { self[UIDensityEnvironmentKey.self] = newValue }
    }
}

private struct UIDensityEnvironmentKey: EnvironmentKey {
    static let defaultValue = UIDensity.default
}

Now you can use the density in any SwiftUI view:

struct TaskRowView: View {
    @Environment(\.uiDensity) private var density
    
    var body: some View {
        HStack {
            Text("Task Title")
            Spacer()
        }
        .padding(EdgeInsets(density.cellMargins))
    }
}

Setting Custom Traits

The real power of custom traits comes when you need to update them app-wide. Instead of changing traits for individual view controllers, you can update the trait at the scene level to propagate changes to all views in your app.

Here's how to update the density setting for the entire app:

@MainActor
class DensityManager: ObservableObject {
    @Published var currentDensity: UIDensity = .default {
        didSet {
            updateAllScenes()
        }
    }
    
    private func updateAllScenes() {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .forEach { scene in
                scene.traitOverrides.uiDensity = currentDensity
            }
    }
}

The updateAllScenes method updates the trait for all connected scenes, which means every UIKit view controller in your app will automatically receive the new density value through the trait propagation system.

For SwiftUI, we need to set the density in the environment from our density manager:

@main
struct MyApp: App {
    @StateObject private var densityManager = DensityManager()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.uiDensity, densityManager.currentDensity)
                .environmentObject(densityManager)
        }
    }
}
Task Flow App Icon

Task Flow: Task Manager App

Task Flow offers innovative features like multi-project tasks and the 'My Task Flow' concept. On top of the project structure, you get an endlessly flexible Task Flow view that suits the way you think and work. Available on iPhone, iPad, Mac and Apple Vision.

Try it now!

Conclusion

Custom trait collections in iOS 17+ give you a clean way to share information across your app's view hierarchy. Unlike traditional approaches like UserDefaults, custom traits automatically propagate changes and integrate smoothly with both UIKit and SwiftUI. We saw how to create a content density trait that lets users control spacing and margins throughout the app.

The pattern we explored works well for many scenarios: user preferences, theme settings, feature flags, or accessibility options. By using custom traits, your app becomes more maintainable and responds better to user needs.

I hope you enjoyed this article. If you have any questions, suggestions, or feedback, please let me know on Twitter.

Thanks for reading!