Theming Apps on iOS is Hard

February 7, 2022

Theming apps (the ability to change up the color scheme for an app from say, a white background with blue links to a light green background with green links) is a pretty common feature across a lot of apps. It’s one of the core features of the new “Twitter Blue” subscription, Tweetbot and Twitterific have had it for awhile, my app Apollo has it (and a significant subset of users use it), and it’s basic table stakes in text editors. When you use the heck out of an app, it’s pretty nice to be able to tweak it in a way that suits you more.

Three examples of different dark themes in the Apollo app

For the longest time, by default, an app had one color scheme. Dark mode didn’t exist at the iOS level, so it was up to apps to have two sets of colors to swap between individually. With iOS 12 Apple made that a lot nicer, and made switching between a light color scheme and a dark color scheme really easy.

The Current Light/Dark Mode System

The current system is great for switching between light mode and dark mode. Each “color” basically has two colors: a light mode version and a dark mode version, and instead of calling it “whiteColor”, the color might be called “backgroundColor”, and have a lightish color for light mode and a darker color for dark mode. You set that on whatever you’re theming, and bam, iOS handles the rest, automatically switching when the iOS system theme changes. Heck, Apple even defines a bunch of built in ones, like “label” and “secondaryLabel”, so you likely don’t even have to define your own colors.

The code defining, say, a custom blue accent/tint color for your app looks basically like:

if lightMode {
    // A rich blue
    return UIColor(hexcode: "007aff") 
} else {
    // A little brighter blue to show up on dark backgrounds
    return UIColor(hexcode: "4BA1FF") 
}

(For a thorough explanation of this system, NSHipster has a great article.)

The Problem

This quickly falls apart when you introduce theming. Maybe blue is a safe bet as your app’s “button color” for 95% of users, but a subset are going to want to make that more personal. Maybe a mint color? A pink! If we’ve learned anything through the craze of app’s like Widgetsmith, people love to make things their own.

But wait, how do we do this when the system is built around only having two options: one for light mode, and one for dark? We might want to have a “Mint” theme, with a delightful green tint instead.

Perhaps something like this?

if lightMode {
    if mintSelected {
        // Minty!
        return UIColor(hexcode: "26C472")
    } else {
        // A rich blue
        return UIColor(hexcode: "007aff")     
    }
} else {
    if mintSelected {
        // Dark mode minty!
        return UIColor(hexcode: "84FFBF")
    } else {
        // A little brighter blue to show up on dark backgrounds
        return UIColor(hexcode: "4BA1FF") 
    }
}

Beautiful! This actually works super well, if we start up our app, iOS will see that mint is selected and choose the mint colors instead.

However, there’s a serious catch. If the app started up in normal (AKA non-minty) mode, and the user selects the mint theme at some point, iOS kinda looks the other way and ignores the change, sticking with blue instead. The conversation kinda goes like:

Me: Hey iOS! The theme is minty now, blue is so last season. Can you update those buttons to mint-colored?

iOS: Well, I asked earlier and you said blue. No take backs. The paint is dry, no updates allowed.

Me: But if the user changes the device theme to dark mode, you’ll happily update the colors! Could you just do that same thing now for me?

iOS: Hard pass.

Me: But the header file for hasDifferentColorAppearanceComparedToTraitCollection even says changes in certain traits could affect the dynamic colors, could you just wrap what those changes call into a general function?

iOS: I said no take backs! But let’s together hope one of the awesome folks who works on me adds that in my next major version!

So what do you do? Have the user force-quit the app and relaunch every time they want to change the theme? That’s not very Apple-y. Reinitialize the app’s view hierarchy? That can mess with lots of things like active keyboards.

Let’s Go Back in Time

Remember how I said in the pre-iOS 12 days, where iOS didn’t even had a dark mode, developers had to get a bit more inventive? Apollo’s theming system was actually written way back then, so I’m pretty familiar with it! Basically how it works is you don’t talk to iOS like above, instead you talk to each view on screen directly. Cut out the middleman!

Leveraging something like NSNotificationCenter (or a more type-safe version via NSHashTable with weak object references) you’d basically go to each view you wanted to color, and say “Hey, you’re blue now, but why don’t you give me your phone number so if anything changes I’ll let you know?” and you’d register that view. Then when the user asked to go to dark mode, you’d quickly phone up all the views in the app and say “Change! Now! Green!” and they would all do that.

The beauty is that when you “phone them up”, you can tell them any color under the sun! You have full control!

Here’s a quick example of what this might look like, somewhat based on how I do it in Apollo:

protocol Themeable: AnyObject {
    func applyTheme(theme: Theme)
}

enum ColorScheme {
    case `default`, pumpkin
}

struct Theme {
    let isLightModeActive: Bool
    let colorScheme: ColorScheme

    var backgroundColor: UIColor {
        switch colorScheme {
            case .default:
                return isForLightMode ? UIColor(hexcode: "ffffff") : UIColor(hexcode: "000000")
            case .pumpkin:
                return isForLightMode ? UIColor(hexcode: "ff6700") : UIColor(hexcode: "733105")
        }
    } 

    // Add more colors for things like tintColor, textColor, separators, inactive states, etc.
}

class ThemeManager: NSObject {
    static let shared = ThemeManager()

    var currentTheme: Theme = // initialize value from UserDefaults or something similar

    private var listeners = NSHashTable<AnyObject>.weakObjects()

    // This would be called by an external event, such as iOS changing or the user selecting a new theme
    func themeChangeDidOccur(toTheme newTheme: Theme) {
        currentTheme = newTheme
        refreshListeners()
    }

    func makeThemeable(_ object: Themeable) {
        listeners.add(object)
        object.applyTheme(theme: currentTheme)
    }

    private func refreshListeners() {
        listenersAllObjects
            .compactMap { $0 as? Themeable }
            .forEach { $0.applyTheme(theme: currentTheme) }
    }
}

// Do this in every view controller/view:
class IceCreamViewController: UIViewController, Themeable {
    let leftBarButtonItem = UIBarButtonItem(title: "Accounts")

    override func viewDidLoad() {
        super.viewDidLoad()
    
        ThemeManager.shared.makeThemeable(self)
    }

    func applyTheme(theme: Theme) {
        // e.g.:
        leftBarButtonItem.tintColor = theme.tintColor
    }
}

So this works but has a lot of downsides. For one, it’s a lot harder. Rather than just setting view.textColor = appTextColor in a single call and have it automatically switch between light and dark mode colors that you defined as needed, you have to set the color, register the view, have a separate theming function, and then go back and talk to that view whenever anything changes. A lot more arduous in comparison.

There’s other aspects to consider as well. Because iOS is smart, when an app goes into the background, iOS quickly takes a screenshot of the app to show up in the app switcher, but it also quickly toggles the app to the opposite theme (so dark mode if the system is in light mode) and takes a screenshot of that as well, so if the system theme changes iOS can instantly update the screenshot in the app switcher.

The result of this is that iOS rapidly asks your app to change its theme twice in a row (to the opposite theme, and then back to the normal), if you don’t do this quickly, you’re in trouble. Indeed, it’s one of my top crashers as of iOS 15, and I assume it’s because I use this old method of talking to every single view to update, and iOS uses a more efficient method under the hood.

You also hit speed bumps you don’t really think of when you start out. For instance, say parts of your app support Markdown rendering where links embedded in a block of text reflect a specific theme’s tint color. When the theme changes, with this system you get that notification, and what do you do? Recompute the NSAttributedString each time you get a theme change? Perhaps only do it the first time, cache the result, and then on theme change iterate over that specific attribute and update only those attributes to the new color. You know what’s a lot nicer than all that rigamarole each time? Just setting the dynamic color in your Markdown renderer/attributed string once, and having iOS handle all the color changes like in the newer solution.

So as you may have guessed I’ve been meaning to update my old system to this newer one. (Wonder why I was writing this blog post?)

(For a thorough writeup on this kind of system, the SoundCloud Developer Blog has a great article, and Joe Fabisevich also has a really cool variation based on Combine.)

SwiftUI

SwiftUI is new and really exciting, and something I’m looking forward to using more in my app. The tricky thing with this antiquated solution is it doesn’t work too well with SwiftUI, subscribing everything into NotificationCenter calls and callbacks isn’t exactly very SwiftUI-esque and ruins a lot of the elegance of creating views in SwiftUI and at best adds a lot of boilerplate.

So if the old system isn’t great, what about the newer, post-iOS 12 dynamic color one? While SwiftUI has its own Color object which unlike UIColor lacks support for custom dynamic colors (I believe) you can initialize a Color object with a UIColor and SwiftUI will dynamically update when light/dark mode changes occur, just like UIKit! Which makes the “newer” solution a lot nicer as it works well in both “worlds”.

What Would be the Perfect Solution from Apple?

The perfect solution would be Apple simply having a method like UIApplication.shared.refreshUserInterfaceStyle() that performs the same thing that occurs when iOS switches from light mode to dark mode. In that situation, there’s a code path/method on iOS that says “Hey app, update all your colors, things have changed”, and simply making it so app developers could call that on their own app would make everything perfect. Theme changes would redraw as requested, no having to force-quit or talk to each and every view manually, and it would work nicely with SwiftUI! (Apple folks: FB9887856)

Hacksville

In the absence of that method (fingers crossed for iOS 16!), can we make our own method that accomplishes effectively the same thing? An app color refresh? Well, there’s a couple ways!

  • Martin Rechsteiner mentioned a clever way on Twitter, wherein you change the app’s displayed color gamut. Since the color profile of the entire app is changing, iOS will indeed update all the colors. The downside is, well, you’re changing the app’s color gamut from say, P3 to SRGB, which can presumably have some effects on how colors look. It shouldn’t be super obvious, since from what I can tell UIImageViews and whatnot have their embedded color profiles separate from app, so pictures and whatnot should still display correctly. But it’s still suboptimal. You could always immediately switch back to the previous color gamut after, but that has the problems of solution 2.
  • If you’re in light mode set overrideUserInterfaceStyle to dark mode on the app’s UIWindow, and then change it back (or vice-versa). The downside here, is that if you do it in the same pass of the runloop, colors will update but traitCollectionDidChange does not fire in the relevant view controllers which may be important for things like CALayer updates. You can dispatch it to the next loop with good ol’ DispatchQueue.main.async { ... } on the second call, but then traitCollectionDidChange will be called twice, and unless you do a bit more work the screen will have a quick flash as it jumps between light and dark mode very quickly.

Of the two, I think I prefer the second solution slightly. Even though it calls the method twice, and flashes a bit, you can negate the flash by putting a view overtop the main window (say, a snapshot from immediately before that pleasantly fades to the new theme) and the traitCollectionDidChange being called twice likely isn’t much concern.

Put Those Two Together? PB & J Sandwich?

Another solution would be to take parts of both systems that work and put them together: use dynamic colors for 97% of the heavy lifting, but when a color has to change immediately in response to a user changing themes, then you use the “notify all the views in the app manually” method. This would likely be fine when going into the background and snapshotting, because that would use dynamic colors, and the “notifying all the views” would only occur when the app is in the foreground with the user manually changing the theme.

Still, I don’t really like that we have to have a separate system maintained where we have to keep track of every view in the app that might need a color change, for the 3% of the time the user might change the theme. That’s a lot of boilerplate and excess code for something that could simply be handled by a refresh method on UIApplication. (And yes, you could say “if it’s that rare, just have them force quit the app or something else gross”, but you want the user to be able to quickly preview different themes without a ton of friction in between.)

So all in all, I think I’m going to go with the overrideUserInterfaceStyle kinda hack, and hope iOS 16 sees a proper, built-in way to refresh the app’s colors. But if you have a better solution I’m all ears, hit me up on Twitter!