How to use custom UIKit trait in SwiftUI

Issue #1013

iOS 17 gave us custom UIKit traits. You can now propagate values through your view hierarchy using the trait collection system. But getting those same values into SwiftUI requires a bridge. The UITraitBridgedEnvironmentKey protocol provides that connection, though making it work reliably takes more than following the obvious pattern.

Why You Need This

Picture building a privacy mode that hides sensitive data across your entire app. Users flip a switch, and account balances blur out in both UIKit table views and SwiftUI detail screens. The trait system handles this elegantly. Set one value at the window scene level, and every view controller and SwiftUI view reads it automatically.

The challenge is keeping UIKit and SwiftUI synchronized. UIKit reads from trait collections. SwiftUI reads from environment values. Without proper bridging, you end up manually wiring these systems together.

Building the Trait

Define your trait with the value type you need. A simple Bool works perfectly for toggles like privacy mode.

@available(iOS 17.0, *)
public struct RedactionTrait: UITraitDefinition, Sendable {
    public static let defaultValue = false
}

This lets you update the trait through the trait collection system. Change it once on the window scene, and it flows down through every view.

windowScene.traitOverrides[RedactionTrait.self] = true

Adding SwiftUI Support

SwiftUI needs its own entry point to this value. Create an environment key using the same type as your trait.

public struct RedactionKey: EnvironmentKey {
    public static let defaultValue = false
}

Now comes the part where most implementations break. The natural approach looks like this:

// This seems right, but doesn't always work
public extension EnvironmentValues {
    var redaction: Bool {
        get { self[RedactionKey.self] }
        set { self[RedactionKey.self] = newValue }
    }
}

That simple passthrough fails in practice. Swift’s optimizer can inline or eliminate the subscript call entirely. When that happens, SwiftUI never discovers your bridge protocol conformance. The connection between traits and environment values breaks silently.

The fix requires explicit logic that prevents optimization.

public extension EnvironmentValues {
    var redaction: Bool {
        get {
            if #available(iOS 17.0, *) {
                return self[RedactionKey.self]
            } else {
                return false
            }
        }
        set {
            self[RedactionKey.self] = newValue
        }
    }
}

The availability check and explicit return force the compiler to preserve the subscript access. This subscript call is what triggers SwiftUI to look for bridge protocol conformance. Without it, your trait changes never reach SwiftUI.

Connecting the Systems

With the environment extension in place, implement the actual bridge protocol.

@available(iOS 17.0, *)
extension RedactionKey: UITraitBridgedEnvironmentKey {
    public static func read(from traitCollection: UITraitCollection) -> Bool {
        traitCollection[RedactionTrait.self]
    }

    public static func write(to mutableTraits: inout any UIMutableTraits, value: Bool) {
        mutableTraits[RedactionTrait.self] = value
    }
}

The read method pulls values from UIKit’s trait collection. The write method pushes values back. SwiftUI calls these methods automatically when the trait changes, keeping everything synchronized.

The iOS 18 Problem

This bridge pattern worked reliably through iOS 17, but iOS 18 introduced a subtle runtime issue. Apps crash during view layout with a witness table lookup failure in the Swift runtime. The crash traces back to the moment SwiftUI tries to access your environment value.

The root cause sits in how iOS 18 handles the UITraitBridgedEnvironmentKey protocol conformance. When SwiftUI looks up the witness table for your RedactionKey during environment access, something in the lookup chain breaks. This isn’t a problem with your code. It’s a regression in the framework’s internal handling of trait-to-environment bridging.

You have three working solutions, each with different trade-offs.

Option 1: Future-proof the availability check

Change your environment value getter to require iOS 26 for the bridged behavior. This keeps the bridge functional while working around the iOS 18 issue.

public extension EnvironmentValues {
    var redaction: Bool {
        get {
            if #available(iOS 26.0, *) {
                return self[RedactionKey.self]
            } else {
                return false
            }
        }
        set {
            self[RedactionKey.self] = newValue
        }
    }
}

The bridge won’t activate on iOS 18, but your app won’t crash. When iOS 26 ships with a fix, the bridge starts working automatically. Until then, you’ll need to manage the value through manual coordination or alternative approaches on iOS 18.

Option 2: Remove the bridge entirely

Delete the UITraitBridgedEnvironmentKey conformance extension. Your trait and environment key still exist, but they operate independently.

// Remove this entire extension
@available(iOS 17.0, *)
extension RedactionKey: UITraitBridgedEnvironmentKey {
    // ...
}

This trades the automatic synchronization for guaranteed stability. You can still use traits in UIKit and environment values in SwiftUI. They just won’t stay synchronized automatically. Set them separately when your redaction state changes.

Option 3: Limit the bridge to iOS 26

Keep the bridge extension but mark it available only from iOS 26 forward.

@available(iOS 26.0, *)
extension RedactionKey: UITraitBridgedEnvironmentKey {
    public static func read(from traitCollection: UITraitCollection) -> Bool {
        traitCollection[RedactionTrait.self]
    }

    public static func write(to mutableTraits: inout any UIMutableTraits, value: Bool) {
        mutableTraits[RedactionTrait.self] = value
    }
}

This approach documents your intent clearly. The bridge exists for iOS versions that handle it correctly. Earlier versions simply won’t see the conformance, so SwiftUI never attempts the problematic witness table lookup.

Which solution fits your needs?

If you need the automatic bridge behavior and can wait for iOS 26, use Option 1 or 3. They’re functionally equivalent, with Option 3 being slightly more explicit about the limitation. If you need something working on iOS 18 today and don’t mind manual coordination, Option 2 gives you immediate stability.

The witness table issue will likely get fixed in a future iOS update. Until then, these workarounds keep your app running while preserving the trait and environment infrastructure for when the bridge becomes reliable again.

Using the Bridge

Managing the trait becomes straightforward. A service layer can handle the toggle logic and trait updates.

@MainActor
final class RedactionService {
    weak var windowScene: UIWindowScene?
    var isEnabled: Bool = false

    func toggle(enabled: Bool) {
        isEnabled = enabled

        if #available(iOS 17.0, *) {
            guard let windowScene else { return }
            windowScene.traitOverrides[RedactionTrait.self] = enabled
        }
    }
}

SwiftUI views access the value through the environment. Create a modifier that reads the trait and applies redaction.

@available(iOS 17.0, *)
struct RedactionModifier: ViewModifier {
    @Environment(\.redaction) private var redaction: Bool

    func body(content: Content) -> some View {
        content
            .privacySensitive()
            .redacted(reason: redaction ? .privacy : [])
    }
}

Apply it wherever you display sensitive information.

Text(accountBalance)
    .modifier(RedactionModifier())
Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation