Add Theming iOS Apps is Still Hard

This commit is contained in:
Shadowfacts 2023-03-20 18:35:50 -04:00
parent 796a5213a2
commit 187f05ea29
1 changed files with 74 additions and 0 deletions

View File

@ -0,0 +1,74 @@
```
title = "Theming iOS Apps is Still Hard"
tags = ["swift"]
date = "2023-03-20 20:29:42 -0500"
short_desc = "Sorry to be the bearer of bad news."
slug = "theming-ios-apps"
```
Sorry to be the bearer of bad news. Last year, Christian Selig wrote [a blog post](https://christianselig.com/2022/02/difficulty-theming-ios/) 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].
[^1]: 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](https://mastodon.social/@christianselig/109790040419220489) for the entire app worked.
<!-- excerpt-end -->
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](https://developer.apple.com/documentation/uikit/uicolor/3238041-init) 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:
```swift
extension UITraitCollection {
var pureBlackDarkMode: Bool {
get {
(value(forKey: "_clientDefinedTraits") as? [String: Any])?[key] as? Bool ?? true
}
set {
var dict = value(forKey: "_clientDefinedTraits") as? [String: Any] ?? [:]
dict[key] = 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:
```swift
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.
```swift
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.