Sorry to be the bearer of bad news. Last year, Christian Selig wrote a blog post about the annoyances of theming iOS apps. I won’t retread his entire article, but the gist of it is that there is no nice way to easily apply a theme to an entire iOS app. Either every view/controller has to listen for theme change notifications and update itself, or you have to resort to hacky workarounds to force all colors to update[1].
The ideal is something that SwiftUI gets right: the environment. You put any values you want in, you can read them at any point in the view hierarchy and changes automatically propagate down. Unfortunately, UIKit has no similar mechanism. Or does it?
UIKit does have a way of defining colors that react to certain changes in their environment—specifically, anything in the trait collection. This works by providing a closure to UIColor which the framework runs when it needs the concrete color. But it’s just an ordinary closure, so why can’t you base the decision on something else, such as the user’s preferences?
If you did so, you could just use your dynamic colors everywhere, and they’d use the right colors based on the selected theme. And when the user’s preferences changed, you’d just need to tell the system the trait collection changed, and it would handle re-resolving all the dynamic colors.
The word “just” there is doing a lot of heavy lifting, though. I initially went spelunking through UIKitCore to try and find a way of forcing a dynamic color update, and found a promising lead. There’s a very attractively named -[UITraitCollection hasDifferentColorAppearanceComparedToTraitCollection:]
method, which sounded exactly like what I was after. Alas, swizzling it was to no avail. The code path the system used when the appearance changed seem to go straight to an internal, non-Objective-C (and thus, not swizzlable) function __UITraitCollectionTraitChangesAlteredEffectiveColorAppearance
.
But, in doing all this, I found another approach that seemed even better. UITraitCollection
has a _clientDefinedTraits
dictionary. And, as the name implies, this goes a long way towards the ideal of the SwiftUI environment in UIKit.
To be clear: this has significant drawbacks. It’s relying on private API that could change at any time. I’m only comfortable doing so because I’m careful to catch Objective-C exceptions whenever I’m accessing it and I’m only using it for a small feature: a preference for switching between the regular iOS pure-black dark mode style, and a non-pure-black one. The failure mode is that the app is still in dark mode, but slightly darker than intended. If themes were a more central aspect of my app, I wouldn’t want to rely on this.
With a simple extension on UITraitCollection
, you can make it look like any other property:
extension UITraitCollection {
var pureBlackDarkMode: Bool {
get {
(value(forKey: "_clientDefinedTraits") as? [String: Any])?["tusker_usePureBlackDarkMode"] as? Bool ?? true
}
set {
var dict = value(forKey: "_clientDefinedTraits") as? [String: Any] ?? [:]
dict["tusker_usePureBlackDarkMode"] = newValue
setValue(dict, forKey: "_clientDefinedTraits")
}
}
convenience init(pureBlackDarkMode: Bool) {
self.init()
self.pureBlackDarkMode = pureBlackDarkMode
}
}
And a dynamic color can read it just like any other trait:
extension UIColor {
static let appBackground = UIColor { traitCollection in
if case .dark = traitCollection.userInterfaceStyle,
!traitCollection.pureBlackDarkMode {
return UIColor(hue: 230/360, saturation: 23/100, brightness: 10/100, alpha: 1)
} else {
return .systemBackground
}
}
}
Applying it is fairly straightforward: you just use setOverrideTraitCollection(_:forChild:)
on a container view controller that contains the app UI. The only wrinkle is that this means the custom trait doesn’t propagate to modally-presented view controllers. Presentation is always performed by the window’s root VC, and so it doesn’t inherit the child’s traits.
But fret not, for another little bit of private API saves the day: UIWindow._rootPresentationController
is actually responsible for the presentation, and since it’s a UIPresentationController
, you can use the regular, public overrideTraitCollection
API. When setting up the window, or when the user’s preference changes, adjust the override collection and it will apply to presented VCs.
if let rootPresentationController = window.value(forKey: "_rootPresentationController") as? UIPresentationController {
rootPresentationController.overrideTraitCollection = UITraitCollection(...)
}
I’ll end by reiterating that this is all a giant hack and echoing Christian’s sentiment that hopefully iOS 16 17 will introduce a proper way of doing this.
Update: Hell yeah
What’s more, the workaround of switching back and forth between user interface styles he describes doesn’t even work reliably. Apparently, only changing the color gamut for the entire app worked. ↩
Comments
Comments powered by ActivityPub. To respond to this post, enter your username and instance below, or copy its URL into the search interface for client for Mastodon, Pleroma, or other compatible software. Learn more.