Themes are usually downplayed as an after-thought instead of being an integral part of the development process. How many times have you inherited a codebase where the design team wants to tweak it, or business wants you to clone the app with a whole different theme. Then you look at the code…
Color and font changes are sprinkled all over storyboards and views!!
Ok you wish they had a bit more foresight when they were making the app the first time, but then they wouldn’t have reached out to you now right? In this post, I’m going to show you the native way of theming an iOS app as intended by Apple. It’s often overlooked, well because…
UIAppearance Sucks
I’m sure most of you have toyed with UIAppearance and quickly gave up on it because of its limitations, especially if you’ve been spoiled with CSS stylesheets for web development or theme resources for Android apps. Apple’s way of theming is quite awkward, but it’s what we got and I urge you not to roll out your own or use yet another dependency.
In a nutshell, UIAppearance
allows you to style UIKit controls globally using a proxy, or a class that stands in as a placeholder which applies itself to the control when it gets added to the window hierarchy. For example, you can globally change labels to red text like this:
UILabel.appearance().textColor = .red
It’s like a static function that gets applied to all future instances. Though, it’s like a sledge hammer for a nail because it applies to ALL labels in the app, even in places you didn’t even know labels existed! This is where most people give up on UIAppearance
.
UIAppearance Sucks… Well Kinda…
Apple’s answer to styles and themes is for you to subclass your UIKit controls and use UIAppearance
on those custom controls like this:
AppLabel.appearance().textColor = .red
That’s the awkward part, Apple wants you to use object-oriented programming to theme your apps. I don’t think they understood that a CSS-class isn’t really a class 🙄.
You can take it one step further though:
AppLabel.appearance(whenContainedInInstancesOf: [MyCustomView.self]).textColor = .red
This will change the appearance of all AppLabel’s only contained in MyCustomView’s. So that’s your other option – to change your UI hierarchy to accommodate styling 🤦♂️. Why couldn’t they just add a new space-delimited string property called styles that gets realized by its children??
Anyways, surprisingly things become really powerful when you start feeding whenContainedInInstancesOf
an array of classes, which defines the hierarchy chain of the control. The specificity let’s you zero in on controls.
AppLabel.appearance(whenContainedInInstancesOf: [MyViewController.self]).textColor = .blue AppLabel.appearance(whenContainedInInstancesOf: [MyCustomView.self]).textColor = .red AppLabel.appearance(whenContainedInInstancesOf: [MyCustomView.self, MyViewController.self]).textColor = .yellow
Another Apple awkwardness is that the outer most parent wins (unlike CSS and the rest of the world). So in the above example, [MyViewController.self]
wins over [MyCustomView.self]
, even though MyCustomView
is the closest parent.
The way it works is the selector starts from top-to-bottom of the hierarchy, which means when it first hits MyViewController
it stops. The label will be blue, not red, even if it’s contained in a MyCustomView
. Fortunately though, the longer your array is (the more specific it is), the higher priority it is. In this case, the labels in MyCustomView
will be yellow if it is on the MyViewController
screen.
Once you understand and toy around with these quirks, it becomes a full-fledged styling framework!
Styleable Controls
First thing we must do is subclass all the controls we want to style in our app. Not the best use of object-oriented programming, but it’s native theming (see rant above). Here’s what I got:
AppLabel.swift:
class AppLabel: UILabel { } class AppHeadline: UILabel { } class AppSubhead: UILabel { } class AppFootnote: UILabel { }
AppButton.swift:
class AppButton: UIButton { } class AppDangerButton: UIButton { }
AppSwitch.swift:
class AppSwitch: UISwitch { }
AppView.swift:
class AppView: UIView { } class AppSeparator: UIView { }
In our storyboard, we will now use these custom classes:
However, we will not change colors in the storyboard.. leave them all on the defaults! Nor will we style and theme the custom controls in the awakeFromNib()
event!
Instead we will style our controls like this:
AppLabel.appearance().textColor = .red AppSubhead.appearance().textColor = .purple AppFootnote.appearance().textColor = .orange AppButton.appearance().setTitleColor(.green, for: .normal) AppButton.appearance().borderColor = .green AppButton.appearance().borderWidth = 1 AppButton.appearance().cornerRadius = 3
This should take place in the AppDelegate.willFinishLaunchingWithOptions
event so it can be ready before any controls are loaded into your app. You can call the UIAppearance
proxy later to change your styles, but it will only apply to newly added controls to the window. All existing controls need to be removed and added back to have the new styles take effect (yes more quirks, but I have a helper below I will mention).
The Theme Protocol
We need swappable themes for our app right? Even if you only have one theme, designing it in such a way will allow you to maintain the styles much more easily and add different themes later.
Here’s a protocol for the themes we can use:
protocol Theme { var tint: UIColor { get } var secondaryTint: UIColor { get } var backgroundColor: UIColor { get } var separatorColor: UIColor { get } var selectionColor: UIColor { get } var labelColor: UIColor { get } var secondaryLabelColor: UIColor { get } var subtleLabelColor: UIColor { get } var barStyle: UIBarStyle { get } }
Then we can extend the protocol to change the appearance for controls using the above properties which themes will conform to. The extension can look something like:
extension Theme { func apply(for application: UIApplication) { application.keyWindow?.tintColor = tint UITabBar.appearance().barStyle = barStyle UINavigationBar.appearance().barStyle = barStyle UINavigationBar.appearance().tintColor = tint UINavigationBar.appearance().titleTextAttributes = [ .foregroundColor: labelColor ] if #available(iOS 11.0, *) { UINavigationBar.appearance().largeTitleTextAttributes = [ .foregroundColor: labelColor ] } UICollectionView.appearance().backgroundColor = backgroundColor UITableView.appearance().backgroundColor = backgroundColor UITableView.appearance().separatorColor = separatorColor UITableViewCell.appearance().backgroundColor = .clear UITableViewCell.appearance().selectionColor = selectionColor UIView.appearance(whenContainedInInstancesOf: [UITableViewHeaderFooterView.self]) .backgroundColor = selectionColor UILabel.appearance(whenContainedInInstancesOf: [UITableViewHeaderFooterView.self]) .textColor = secondaryLabelColor AppLabel.appearance().textColor = labelColor AppSubhead.appearance().textColor = secondaryLabelColor AppFootnote.appearance().textColor = subtleLabelColor AppButton.appearance().borderColor = tint AppButton.appearance().setTitleColor(tint, for: .normal) AppButton.appearance().borderWidth = 1 AppButton.appearance().cornerRadius = 3 AppDangerButton.appearance().borderWidth = 0 AppDangerButton.appearance().setTitleColor(labelColor, for: .normal) AppDangerButton.appearance().backgroundColor = tint AppSwitch.appearance().tintColor = tint AppView.appearance().backgroundColor = backgroundColor AppSeparator.appearance().backgroundColor = separatorColor AppSeparator.appearance().alpha = 0.5 } }
And the themes that conform would only have to fill in the property values:
struct DarkTheme: Theme { let tint: UIColor = .yellow let secondaryTint: UIColor = .green let backgroundColor: UIColor = .black let separatorColor: UIColor = .lightGray let selectionColor: UIColor = .init(red: 38/255, green: 38/255, blue: 40/255, alpha: 1) let labelColor: UIColor = .white let secondaryLabelColor: UIColor = .lightGray let subtleLabelColor: UIColor = .darkGray let barStyle: UIBarStyle = .black }
Here’s what another theme would look like:
struct LightTheme: Theme { let tint: UIColor = .blue let secondaryTint: UIColor = .orange let backgroundColor: UIColor = .white let separatorColor: UIColor = .lightGray let selectionColor: UIColor = .init(red: 236/255, green: 236/255, blue: 236/255, alpha: 1) let labelColor: UIColor = .black let secondaryLabelColor: UIColor = .darkGray let subtleLabelColor: UIColor = .lightGray let barStyle: UIBarStyle = .default }
Finally you call the theme early in the app lifecycle:
@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { private let theme = DarkTheme() var window: UIWindow? func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { theme.apply(for: application) return true } }
There you have it – native, simple theming for your iOS app.. no dependency, magic, or singleton!
Extending Themes
What if one of your custom themes wants to add extra styling? Implementing the func apply(for application: UIApplication)
on the theme will wipe out existing logic which is not what we want, so instead I can add a func extend()
to the protocol and call it within the apply(for:)
extension:
protocol Theme { ... func apply(for application: UIApplication) func extend() } extension Theme { func apply(for application: UIApplication) { ... extend() } func extend() { // Optionally extend theme } }
You see how apply(for:)
calls extend()
right in the protocol extension? Now the themes that conform can simply put their extended styling logic in extend()
:
struct LightTheme: Theme { ... } extension LightTheme { func extend() { let proxy1 = UIImageView.appearance(whenContainedInInstancesOf: [UITableViewCell.self]) proxy1.borderColor = separatorColor proxy1.borderWidth = 1 let proxy2 = UIImageView.appearance(whenContainedInInstancesOf: [UIButton.self, UITableViewCell.self]) proxy2.borderWidth = 0 } }
Changing Themes
Another reason why most people abandon UIAppearance
is you can’t change styles for existing controls that are already loaded. Apple makes it clear in the documentation:
iOS applies appearance changes when a view enters a window, it doesn’t change the appearance of a view that’s already in a window. To change the appearance of a view that’s currently in a window, remove the view from the view hierarchy and then put it back.
It’s pretty ridiculous that every single control has to be removed and added again to realize the new styles. After changing UIAppearance
values, you have to traverse through the entire UIWindow
hierarchy and remove/add controls:
UIApplication.shared.windows.forEach { window in window.subviews.forEach { view in view.removeFromSuperview() window.addSubview(view) } }
It makes my stomach turn, but I guess users shouldn’t be changing themes often – plus it’s still native theming right?
Take it one step further and turn it into an extension:
public extension UIWindow { /// Unload all views and add back. /// Useful for applying `UIAppearance` changes to existing views. func reload() { subviews.forEach { view in view.removeFromSuperview() addSubview(view) } } } public extension Array where Element == UIWindow { /// Unload all views for each `UIWindow` and add back. /// Useful for applying `UIAppearance` changes to existing views. func reload() { forEach { $0.reload() } } }
Now in your original Theme
protocol, you can abstract it away:
protocol Theme { ... } extension Theme { func apply(for application: UIApplication) { ... // Ensure existing views render with new theme application.windows.reload() }
Note that the first time the theme gets applied in AppDelegate.willFinishLaunchingWithOptions
, the window
object won’t have any subviews in it so it will just be ignored. The reload
will only run if called after AppDelegate.willFinishLaunchingWithOptions
.
Beyond Colors
So far I’ve only emphasized colors, but you can style fonts with UIAppearance
, but I prefer to only use the built-in ones in storyboard because it respects dynamic font sizes. If your designer insists, you’ll want to support accessibility which is a whole other topic.
But did you know you can use UIAppearance
on just about any property? You can even change text properties for all UILabel
controls! Of course this would be obnoxious to do, but it’s possible:
UILabel.appearance().text = "Hello World!"
You could, however, add your own custom properties and set them via UIAppearance
as well! Let’s take the selected highlight color for UITableViewCell
. This is not a native property from UIKit
unfortunately, so you’d have to do something like this:
let selectedView = UIView() selectedView.backgroundColor = .lightGray myTableViewCell.selectedBackgroundView = selectedView
You could take it one step further and turn it into an extension:
extension UITableViewCell { /// The color of the cell when it is selected. var selectionColor: UIColor? { get { return selectedBackgroundView?.backgroundColor } set { guard selectionStyle != .none else { return } selectedBackgroundView = UIView().with { $0.backgroundColor = newValue } } } }
When you try to update this property via UIAppearance
, you’ll notice it doesn’t do anything. To expose your custom properties to UIAppearance
, you have to add the dynamic
keyword to the property to force the Swift compiler to use an Objective-C message that UIAppearance
relies on:
extension UITableViewCell { /// The color of the cell when it is selected. @objc dynamic var selectionColor: UIColor? { get { return selectedBackgroundView?.backgroundColor } set { guard selectionStyle != .none else { return } selectedBackgroundView = UIView().with { $0.backgroundColor = newValue } } } }
Now you can do this:
UITableViewCell.appearance().selectionColor = .init(red: 38/255, green: 38/255, blue: 40/255, alpha: 1)
At this point, we got everything we need.
Demo Time!
I put together a sample project with a segmented control to switch between themes:
The Theme
protocol that does the heavy lifting looks like this:
protocol Theme { var tint: UIColor { get } var secondaryTint: UIColor { get } var backgroundColor: UIColor { get } var separatorColor: UIColor { get } var selectionColor: UIColor { get } var labelColor: UIColor { get } var secondaryLabelColor: UIColor { get } var subtleLabelColor: UIColor { get } var barStyle: UIBarStyle { get } func apply(for application: UIApplication) func extend() } extension Theme { func apply(for application: UIApplication) { application.keyWindow?.tintColor = tint UITabBar.appearance().with { $0.barStyle = barStyle $0.tintColor = tint } UINavigationBar.appearance().with { $0.barStyle = barStyle $0.tintColor = tint $0.titleTextAttributes = [ .foregroundColor: labelColor ] if #available(iOS 11.0, *) { $0.largeTitleTextAttributes = [ .foregroundColor: labelColor ] } } UICollectionView.appearance().backgroundColor = backgroundColor UITableView.appearance().with { $0.backgroundColor = backgroundColor $0.separatorColor = separatorColor } UITableViewCell.appearance().with { $0.backgroundColor = .clear $0.selectionColor = selectionColor } UIView.appearance(whenContainedInInstancesOf: [UITableViewHeaderFooterView.self]) .backgroundColor = selectionColor UILabel.appearance(whenContainedInInstancesOf: [UITableViewHeaderFooterView.self]) .textColor = secondaryLabelColor AppLabel.appearance().textColor = labelColor AppHeadline.appearance().textColor = secondaryTint AppSubhead.appearance().textColor = secondaryLabelColor AppFootnote.appearance().textColor = subtleLabelColor AppButton.appearance().with { $0.setTitleColor(tint, for: .normal) $0.borderColor = tint $0.borderWidth = 1 $0.cornerRadius = 3 } AppDangerButton.appearance().with { $0.setTitleColor(backgroundColor, for: .normal) $0.backgroundColor = tint $0.cornerRadius = 3 } AppSwitch.appearance().with { $0.tintColor = tint $0.onTintColor = tint } AppStepper.appearance().tintColor = tint AppSlider.appearance().tintColor = tint AppSegmentedControl.appearance().tintColor = tint AppView.appearance().backgroundColor = backgroundColor AppSeparator.appearance().with { $0.backgroundColor = separatorColor $0.alpha = 0.5 } AppView.appearance(whenContainedInInstancesOf: [AppView.self]).with { $0.backgroundColor = selectionColor $0.cornerRadius = 10 } // Style differently when inside a special container AppLabel.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).textColor = subtleLabelColor AppHeadline.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).textColor = secondaryLabelColor AppSubhead.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).textColor = secondaryTint AppFootnote.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).textColor = labelColor AppButton.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).with { $0.setTitleColor(labelColor, for: .normal) $0.borderColor = labelColor } AppDangerButton.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).with { $0.setTitleColor(subtleLabelColor, for: .normal) $0.backgroundColor = labelColor } AppSwitch.appearance(whenContainedInInstancesOf: [AppView.self, AppView.self]).with { $0.tintColor = secondaryTint $0.onTintColor = secondaryTint } extend() // Ensure existing views render with new theme // https://developer.apple.com/documentation/uikit/uiappearance application.windows.reload() } func extend() { // Optionally extend theme } }
Then you can add as many themes as you want:
struct OceanTheme: Theme { let tint: UIColor = .blue let secondaryTint: UIColor = .orange let backgroundColor: UIColor = .cyan let separatorColor: UIColor = .lightGray let selectionColor: UIColor = .init(red: 38/255, green: 38/255, blue: 40/255, alpha: 1) let labelColor: UIColor = .magenta let secondaryLabelColor: UIColor = .lightGray let subtleLabelColor: UIColor = .darkGray let barStyle: UIBarStyle = .default }
Finally in my segmented control change event, I can simply change themes like this:
@IBAction func themeSegmentedControlChanged(_ sender: UISegmentedControl) { let theme: Theme switch sender.selectedSegmentIndex { case 1: theme = LightTheme() case 2: theme = OceanTheme() default: theme = DarkTheme() } theme.apply(for: UIApplication.shared) }
The complete source code can be downloaded and run here.
Conclusion
UIAppearance
has been around since iOS 5, but it is often overlooked and underestimated. Apple uses this framework to style its own apps, such as Find Friends and many other apps. Sticking with native functionality is always a good idea and hopefully I’ve given you a newfound appreciation for the built-in theming framework.
To learn more about UIAppearance
, check out these resources:
- WWDC 2011: Customizing the Appearance of UIKit Controls
- WWDC 2012: Advanced Appearance Customization on iOS
- Understanding UIAppearance Container Hierarchies
Happy Coding!!
[contentblock id=4 img=gcb.png]
This is a great article! Clear and extremely useful.
Thank you!
This is an excellent article and I am using this in two apps now. However, I discovered an issue when I have a window that displays a keyboard when trying to edit a textfield or view. I use IQKeyboardManager to move the views when the keyboard is displayed, but I have also just coded it myself and the problem persists. When I change the them, the keyboard movement goes all wonky (a technical term)
It turns out that iOS creates a new system window with UITextEffectsWindow class under the hood whenever the keyboard is displayed. If you remove it, your keyboard behavior may be negatively affected.
I have resolved this issue in my apps by modifying your UIWindow,swift file for the Window Extension
public extension UIWindow {
/// Unload all views and add back.
/// Useful for applying `UIAppearance` changes to existing views.
func reload() {
if !isKind(of: NSClassFromString(“UITextEffectsWindow”) ?? NSString.classForCoder()) {
subviews.forEach { view in
view.removeFromSuperview()
addSubview(view)
}
}
}
}
Ugh, I wasted a day tracking this down, find the issue and when I came here to report it you already did 🙂
This seems to affect iOS 13 only.
Wasted 2 days 😅 and it also affects iOS 14.
Specifically, the issue is a runtime layout issue with the message “Position and height are ambiguous for UIInputSetContainerView”.
I was able to resolve this by skipping this view using the following code in the UIWindow extension:
func reload() {
for subview in subviews {
if String(describing: type(of: subview)) == “UIInputSetContainerView” {
continue
}
subview.removeFromSuperview()
addSubview(subview)
}
}
Man this article is so underrated !!
Excellent Theming, so far the best i found, i wonder why apple is not working on themes as a core service.
What’s body of `with` function you are calling after appearance() ?
Good catch! It’s a nifty utility I found:
“`
public protocol With {}
public extension With where Self: Any {
/// Makes it available to set properties with closures just after initializing.
///
/// let label = UILabel().with {
/// $0.textAlignment = .center
/// $0.textColor = UIColor.black
/// $0.text = “Hello, World!”
/// }
@discardableResult
func with(_ block: (Self) -> Void) -> Self {
// https://github.com/devxoul/Then
block(self)
return self
}
}
extension NSObject: With {}
“`
Hi Basem,
Great article, thanks for sharing with us. How would you recommend managing themed UIImages (both vector and non-vector) with compatibility in mind iOS 10+?
I’m seeing 3 options out there and leaning towards #1, but wanted to get your thoughts if you’ve worked through this already.
1. Store tintColor in Theme+Dark/Light files. Set UIImage’s withRenderingMode to .always template. Set imageView’s tintColor to appropriate value upon theme change.
2. Create a light & dark version of each image stored within Assets.xcassets, storing UIImage string names in Theme+Dark/Light files.
3. Create a UIImage extension to invert Colors using CIFilter.
Thank you!
Great article and the first that has actually addressed the minefield of Appearance and explained it well. I only have one question:
If I have, for example, a UILabel that I make a subclass of AppLabel, the themes work and different themes apply as expected based on your code.
But if I need to change the text color of one label, I can do this with:
myLabel.textColor = UIColor.red
And this works.
But then, if I want to refresh that view and reapply the original theme, it will only apply the theme to the original AppLabel subclasses and not the one that I changed to red. What am I missing in my understanding of how this works/should work?
I’ve found that trying to reapply a theme, using for example:
theme.apply(for: UIApplication.shared)
doesn’t work for UI objects that have been directly accessed like the example I mentioned above. If I call
self.loadView() – which Apple says not to call directly
is the only way I can get the label to reset back to the theme colors. Any ideas?