How do dynamic colours, dark mode and the UIAppearance proxy work?
Predict first
What does UIColor.label resolve to in each style? (Red component)
import UIKit
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
UIColor.label.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)).getRed(&r, green: &g, blue: &b, alpha: &a)
let dark = r
UIColor.label.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)).getRed(&r, green: &g, blue: &b, alpha: &a)
print(dark.rounded(), r.rounded())
- 1.0 0.0
- 0.0 1.0
- 1.0 1.0
Predict first
When does UILabel.appearance().textColor take effect?
import UIKit
UILabel.appearance().textColor = .red
let l = UILabel()
let w = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100))
let before = l.textColor == .red
w.addSubview(l)
print(before, l.textColor == .red)
- true true
- false true
- false false
Mental model. A dynamic colour is a chameleon: one object, a different shade depending on the room it is in. A copied CGColor is a photo of the chameleon taken in one room.
Dynamic colours make dark mode nearly free. A colour such as UIColor.label, or an asset-catalogue colour with light and dark variants, or UIColor { traits in … } with a provider closure, resolves when it is used, against the current trait collection, so the same UIColor object gives different RGB values in light and dark. Views redraw when the style changes, but a CGColor you copied earlier (for a layer’s border or shadow) is already resolved and does not update, so reapply layer colours when traits change, using registerForTraitChanges or resolvedColor(with:). Use semantic colours (.label, .systemBackground, .secondaryLabel) instead of fixed black and white, and images from the asset catalogue with dark variants.
UIAppearance is the proxy for styling every instance of a class: UILabel.appearance().textColor = …. It applies to views when they join a window, not to ones already on screen, and only to properties marked UI_APPEARANCE_SELECTOR. It is blunt (global, hard to scope) and for navigation, tab and toolbar bars it has been replaced by explicit appearance objects such as UINavigationBarAppearance with standardAppearance and scrollEdgeAppearance. Prefer configuring components in one style helper over scattering proxy calls, and use UIAppearance.appearance(whenContainedInInstancesOf:) to narrow the scope.
The semantic label colour is white in dark mode and black in light mode.
The proxy applies when the view enters a window, not at creation.
| Resolves | Updates on style change | |
|---|---|---|
UIColor.label | at use time | yes, automatically |
color.cgColor copied to a layer | once, at copy time | no, you must reapply |
UIAppearance proxy | when a view joins a window | not for views already shown |
Common traps
- Setting
layer.borderColor = color.cgColoronce. It is frozen at that appearance and looks wrong after a switch. - Expecting
appearance()to restyle views already on screen. It applies as views enter a window.
They might follow up with
- What is
scrollEdgeAppearance? The bar’s look when content is scrolled to its edge, often transparent, separate from the standard look. - How do you test dark mode quickly? Previews, the simulator’s appearance toggle, and the environment override in the debugger.
Related questions
- 7.7.1 What is a trait collection, and how do size classes and trait changes drive adaptive layout?
- 7.4.1 How does UINavigationController manage its stack, and where do the bar items and back button come from?
- 7.2.1 What is the relationship between UIView and CALayer?
← What is the safe area, and how do you keep content clear of the…