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.
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())
What Makes This Work
The key insight is understanding when SwiftUI checks for bridge protocol conformance. That check happens during the environment key subscript access. A direct passthrough computed property can be optimized away before SwiftUI ever looks for the conformance. The explicit availability check and return statement prevent that optimization.
This isn’t documented behavior from Apple. You won’t find it in the official documentation or WWDC sessions. But it’s the difference between code that works reliably and code that breaks in unexpected ways.
The pattern applies beyond privacy redaction. Any value you want to share between UIKit and SwiftUI benefits from this approach. Keep the value type simple. Use the explicit availability check in your environment extension. Set traits at the window scene level for app-wide propagation.
When you get this right, trait changes flow seamlessly between frameworks. UIKit and SwiftUI stay synchronized without manual coordination. That’s the power of the trait system working as intended.
Start the conversation