How do dynamic colours, dark mode and the UIAppearance proxy work?

7.7.3 · L2 · Interview

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.

ResolvesUpdates on style change
UIColor.labelat use timeyes, automatically
color.cgColor copied to a layeronce, at copy timeno, you must reapply
UIAppearance proxywhen a view joins a windownot for views already shown

Common traps

  • Setting layer.borderColor = color.cgColor once. 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…