Please note: the following is for informational purposes only. Use code snippets and examples in this post at your own risk. Find the source on Github: https://github.com/datwelk/RDRAppearance
UIAppearance
is amongst the more senior citizens of the iOS ecosystem, being introduced all the way back when iOS 5.0 arrived. Some more options were added in iOS 8.0 and 9.0, but chances are that it might be sunset in the next 5-10 years as SwiftUI reduces the need for the appearance proxy.
A large disadvantage of UIAppearance
if being used for applying a user theme (e.g. a day / night theme) is that changes to the appearance proxy are only applied when the view is added back into the view hierarchy. This will naturally happen when the user taps around in the application. It’s not a great look for the user if views gradually move over to a dark theme once the sun sets and the user taps around.
Hence the most common implementation is to iterate through the window hierarchy on a theme change, remove all views from the hierarchy and re-add them. This seems computationally quite expensive, and ultimately completely unnecessary since all we want is just invoke a few selectors with updated values.
Moreover, re-adding views into the view hierarchy could have implications for (custom) view controller presentations. In our application, we saw that theme switching during a custom view controller transition did not play nicely with that transition and caused the transition to be in a bad state.
UIAppearance: what does it do?
There is some information available on the high level implementation of the UIAppearance
protocol, e.g. this great post by Peter Steinberger.
[[UIButton appearance] setBackgroundColor:[UIColor whiteColor]];
Let’s have a look at the above example. If we look at the definition of +appearance
on the UIAppearance
protocol, we will notice that the instancetype
designation of the return value tricks the compiler into thinking that the return value of this method is an instance of the class that it was invoked on. Hence the compiler will not complain when we call setBackgroundColor:
, since this is a method that is implemented on UIButton
through its inheritance from UIView
.
@protocol UIAppearance <NSObject>
+ (instancetype)appearance;
@end
In reality, the +appearance
call does not return an instance of the UIButton
class. Apple returns a proxy class (likely an instance of NSProxy
, one of the foundational members of Objective-C together with NSObject
). Adding runtime breakpoints shows us that it will return an instance of the private class _UIAppearance
.
The Objective-C runtime will be unable to find an implementation of setBackgroundColor:
on _UIAppearance
, and this is where the following NSProxy
methods kick in:
- (void)forwardInvocation:(NSInvocation *)invocation;
- (BOOL)respondsToSelector:(SEL)aSelector
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;
Using these three methods, we can tell the runtime that we can actually take care of that method call in order to prevent an unrecognized selector sent to instance exception being thrown.
In the forwardInvocation:
call, the proxy will receive the arguments that we passed when we set up the theme, in this case the white color instance. It will record this invocation including its retained arguments. Then when a view is added into the view hierarchy, the appearance implementation in UIKit will find out which _UIAppearance
instance is responsible for styling that view, based on potential container hierarchy conditions supplied to appearanceWhenContainedInInstancesOfClasses:
. The instance will be retrieved and the invocation will be replayed with the arguments that were last recorded.
A custom appearance proxy for UIView
In the following section we will focus on UIView exclusively. UIAppearance is also available for UIBarItem, but we will ignore that one.
What if we let Apple’s appearance proxy do its thing, while at the same time we tap into some places of the flow to record theming information that we can use at a later point in time, when the user switches their theme?
We can achieve exactly what we want by using what could be the most powerful feature of the Objective-C runtime: method swizzling. Method swizzling could have unwanted side effects, and it should generally be avoided at all costs. However this post is for informational purposes to see if we can get realtime theme switching behaviour using the built-in UIAppearance
protocol, and we will now demonstrate that this is possible without calling private API or making too many implementation-specific assumptions. Instead, our implementation is based on the core concept behind UIAppearance
itself which is inherent to the dynamic nature of the Objective-C runtime. Make your own assessment if you want to try this yourself. Do note that method swizzling is definitely used in production apps more than you would like to think.
Let’s start by defining our own appearance protocol:
@protocol RDRAppearance <UIAppearance>
+ (instancetype)rdr_appearance NS_SWIFT_NAME(rdr_appearance());
+ (instancetype)rdr_appearanceWhenContainedInInstancesOfClasses:(NSArray<Class <UIAppearanceContainer>> *)containerTypes NS_SWIFT_NAME(rdr_appearance(whenContainedInInstancesOf:));
@end
We can now define compliance of UIView
to our appearance protocol in a category:
@interface UIView (RDRAppearance) <RDRAppearance>
@end
In the implementation, we will return an instance of our own appearance proxy dubbed RDRAppearanceProxy
:
@implementation UIView (RDRAppearance)
+ (instancetype)rdr_appearance
{
id originalProxy = [self appearance];
id existingWrapper = objc_getAssociatedObject(originalProxy, @selector(rdr_appearance));
if (existingWrapper != nil) {
return existingWrapper;
}
id newProxy = (id)[RDRAppearanceProxy withProxy:originalProxy];
objc_setAssociatedObject(originalProxy, @selector(rdr_appearance), newProxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return newProxy;
}
+ (instancetype)rdr_appearanceWhenContainedInInstancesOfClasses:(NSArray<Class<UIAppearanceContainer>> *)containerTypes
{
id originalProxy = [self appearanceWhenContainedInInstancesOfClasses:containerTypes];
id existingWrapper = objc_getAssociatedObject(originalProxy, @selector(rdr_appearance));
if (existingWrapper != nil) {
return existingWrapper;
}
id newProxy = (id)[RDRAppearanceProxy withProxy:originalProxy];
objc_setAssociatedObject(originalProxy, @selector(rdr_appearance), newProxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return newProxy;
}
@end
What we do here is as follows:
We obtain the original appearance proxy from Apple. This will return an instance of
_UIAppearance
. We notice that Apple returns an appearance proxy that is class-specific.We want the caller to use this API just how the Apple API is being used. We don’t want to make the caller responsible for storing the returned proxy, nor do we want to keep track of those ourselves somewhere. Hence we tie one instance of our appearance proxy, to an instance of Apple’s appearance proxy: we check if the Apple proxy already holds an instance of our proxy and if so, we return it. Otherwise we set the newly created instance of our proxy on the Apple proxy and have it retain our proxy instance.
Our proxy wraps the original proxy as you can see from the constructor +withProxy:
. It is implemented as a subclass of NSProxy
:
@interface RDRAppearanceProxy: NSProxy
@property (nonatomic, strong) NSHashTable *recordedInvocations;
@end
@implementation RDRAppearanceProxy
+ (instancetype)withProxy:(NSObject *)proxy
{
RDRAppearanceProxy *wrapper = [[self class] alloc];
[wrapper configureWithProxy:proxy];
return wrapper;
}
- (void)configureWithProxy:(NSObject *)proxy
{
self.proxy = proxy;
self.recordedInvocations = [NSHashTable weakObjectsHashTable];
__weak typeof(self) weakSelf = self;
[[NSNotificationCenter defaultCenter] addObserverForName:RDRDidRefreshAppearanceNotificationName object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
[weakSelf cleanup];
}];
}
- (void)cleanup
{
[self.recordedInvocations removeAllObjects];
}
@end
The constructor is quite straightforward: it stores the Apple proxy weakly (to prevent a retain cycle, since the Apple proxy strongly holds a reference to our proxy). Furthermore, we setup a listener for a notification that we fire once we are done with the theme switch. In the cleanup method we get rid of invocations that we have stored in a hash table.
Those invocations are the appearance invocations that the programmer calls when setting up the new theme. We want those to be stored only until the invocations have been replayed on all views in the view hierarchy: because whenever a new view enters the hierarchy, Apple’s appearance proxy will take care of styling it.
Now let’s have a look at the implementation of the proxy methods on our proxy class:
NSString * RDRIdentifierForInvocation(NSInvocation *invocation) {
NSMutableString *identifier = [NSMutableString stringWithFormat:@"%@", NSStringFromSelector(invocation.selector)];
for (NSUInteger i = 2; i < invocation.methodSignature.numberOfArguments; i++) {
const char *argumentType = [invocation.methodSignature getArgumentTypeAtIndex:i];
if (strcmp(argumentType, @encode(id)) == 0) {
__unsafe_unretained id argument;
[invocation getArgument:&argument atIndex:i];
[identifier appendFormat:@"%p", argument];
}
}
return [identifier copy];
}
+ (NSMapTable *)appearanceInstances
{
static NSMapTable *table = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
table = [NSMapTable strongToWeakObjectsMapTable];
});
return table;
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
NSString *identifier = RDRIdentifierForInvocation(invocation);
[[[self class] appearanceInstances] setObject:self forKey:identifier];
[self.recordedInvocations addObject:invocation];
invocation.target = _proxy;
[invocation invoke];
}
- (BOOL)respondsToSelector:(SEL)aSelector
{
return [self.proxy respondsToSelector:aSelector];
}
Our end goal is to iterate over all views in the hierarchy, and replay invocations that were triggered to style that view. The object responsible for styling a view is an instance of Apple’s appearance proxy. However there is no (public) method of finding out which appearance proxy instance was responsible for styling which view.
In the forwardInvocation:
method that is called when the programmer sets up a new theme, we receive the exact arguments and selector name. We convert these values into a string signature. We only implement this for object arguments although UIAppearance
also supports things like C structs and booleans. We assume that for our use case across our themes, only object types like fonts (UIFont
) and colors (UIColor
) differ.
We take the selector name and append the pointer addresses of the arguments supplied. The invocation retains its arguments, so these addresses should stay the same in the short timespan between the programmer setting up a new theme, and a refresh of the UI being triggered using our implementation.
This signature is linked to the appearance instance. So once we are done with calling all the rdr_appearance
methods, we will have an overview of all appearance proxies including the method name and exact argument values (e.g. colors and fonts) that it is responsible of forwarding and applying to the UI.
At the same time, when we are done with handling the message, we pass it onto Apple’s proxy to hook into the standard UIAppearance
logic.
Apple’s proxy will also record the invocations, and replay them onto specific UIView
instances once they move onto the screen. Fortunately this is all done using public API’s on NSInvocation
: -invokeUsingImp:
and -invokeWithTarget:
.
By swizzling those methods on NSInvocation
, and capturing only those invocations that are destined for objects that conform to our RDRAppearance
protocol, we can hook into this flow and capture the exact moment when Apple applies the appearance defined styles on the views. And of course I do realize that swizzling an instance method on such a foundational class like NSInvocation
could in theory have profound implications - that’s why this post is for informational and experimental purposes only.
Let’s look at the implementation of our NSInvocation
category:
@interface NSInvocation (RDRAppearance)
- (void)rdr_originalInvokeWithTarget:(id)target;
@end
@implementation NSInvocation (RDRAppearance)
+ (void)load
{
if (self == [NSInvocation class]) {
method_exchangeImplementations(
class_getInstanceMethod([self class], @selector(invokeUsingIMP:)),
class_getInstanceMethod([self class], @selector(rdr_originalInvokeUsingIMP:))
);
method_setImplementation(
class_getInstanceMethod([self class], @selector(invokeUsingIMP:)),
method_getImplementation(class_getInstanceMethod([self class], @selector(rdr_invokeUsingIMP:)))
);
method_exchangeImplementations(
class_getInstanceMethod([self class], @selector(invokeWithTarget:)),
class_getInstanceMethod([self class], @selector(rdr_originalInvokeWithTarget:))
);
method_setImplementation(
class_getInstanceMethod([self class], @selector(invokeWithTarget:)),
method_getImplementation(class_getInstanceMethod([self class], @selector(rdr_invokeWithTarget:)))
);
}
}
- (void)rdr_originalInvokeUsingIMP:(IMP)imp { }
- (void)rdr_invokeUsingIMP:(IMP)imp
{
id target = self.target;
if ([target conformsToProtocol:@protocol(RDRAppearance)]) {
[target rdr_willReceiveInvocation:self];
}
[self rdr_originalInvokeUsingIMP:imp];
}
- (void)rdr_invokeWithTarget:(id)target
{
if ([target conformsToProtocol:@protocol(RDRAppearance)]) {
[target rdr_willReceiveInvocation:self];
}
[self rdr_originalInvokeWithTarget:target];
}
- (void)rdr_originalInvokeWithTarget:(id)target { }
@end
It hooks into two out of three ways of invoking an NSInvocation
and provides an new implementation. In the implementation, we check if the target conforms to our appearance protocol, and if it does, we forward the method to a new method on our appearance protocol. Also note that we expose the original implementation of invokeWithTarget:
on our category.
The new method on our appearance proxy generates the same signature as before from the invocation that is now passed in. We note that Apple does not simply forward the recorded invocation, but constructs a new one. Then on the view instance itself, we maintain a strongToWeakObjectsMapTable
to record which appearance instance was responsible for calling which particular invocation of a styling method with specific argument values:
- (void)rdr_willReceiveInvocation:(NSInvocation *)invocation
{
id appearance = [RDRAppearanceProxy appearanceForInvocation:invocation];
if (appearance) {
[self.rdr_appearanceBySelector setObject:appearance forKey:NSStringFromSelector(invocation.selector)];
}
}
This will allow us to easily replay an invocation when we iterate through the view hierarchy when forcing a UI refresh.
The map table is stored in a category on NSObject
(since UIAppearance
inherits from the NSObjectProtocol
). We could also implement this on a UIView
category since we focus only on the appearance implementation in (subclasses of) UIView
.
@interface NSObject (RDRAppearance)
@property (nonatomic, strong) NSMapTable *rdr_appearanceBySelector;
@end
@implementation NSObject (RDRAppearance)
@dynamic rdr_appearanceBySelector;
- (NSMapTable *)rdr_appearanceBySelector
{
NSMapTable *result = objc_getAssociatedObject(self, @selector(rdr_appearanceBySelector));
if (!result) {
result = [NSMapTable strongToWeakObjectsMapTable];
self.rdr_appearanceBySelector = result;
}
return result;
}
- (void)setRdr_appearanceBySelector:(NSMapTable *)rdr_appearanceBySelector
{
objc_setAssociatedObject(self, @selector(rdr_appearanceBySelector), rdr_appearanceBySelector, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@end
The two main pieces of the flow are defined now. Just the final part is missing, where we force a refresh of the UI. Let’s quickly reiterate what we have until now:
We maintain an instance of our own appearance proxy. This instance records all invocations that the programmer did to style appearance conforming views in the app. The instance is kept alive by Apple’s proxy instance. The instance also registers which particular invocation (including argument values) of an appearance method it’s made responsible for.
We capture the moment that Apple styles views just before they appear on screen. At this point in time, Apple has determined which appearance proxy instance is responsible for styling which view. We don’t want to implement this logic ourselves, but this goes at the cost of having to swizzle
NSInvocation
. A more durable implementation would probably be to also implement this logic ourselves. We register a signature of the invocation (including argument values) and the responsible appearance instance on the view that is styled.
The final piece is to trigger a refresh. To do this, we iterate over the view hierarchy including any potentially presented view controllers, and for each selector that was called when the view was styled by Apple, we find out which appearance instance was responsible for doing that. Then we find the newly recorded invocation for that selector, and apply it with the new values on the view.
@interface UIView (RDRAppearanceRefresh)
- (void)rdr_applyAppearance;
@end
@implementation UIView (RDRAppearanceRefresh)
- (void)rdr_applyAppearance
{
for (NSString *selectorName in self.rdr_appearanceBySelector.keyEnumerator.allObjects) {
RDRAppearanceProxy *appearance = [self.rdr_appearanceBySelector objectForKey:selectorName];
for (NSInvocation *recordedInvocation in [appearance.recordedInvocations copy]) {
if ([NSStringFromSelector(recordedInvocation.selector) isEqualToString:selectorName]) {
[recordedInvocation rdr_originalInvokeWithTarget:self];
}
}
}
for (UIView *v in self.subviews) {
[v rdr_applyAppearance];
}
}
+ (void)rdr_refreshAppearance
{
for (UIWindow *window in [UIApplication sharedApplication].windows) {
UIViewController *v = window.rootViewController.presentedViewController;
while (v != nil) {
[v.view rdr_applyAppearance];
v = v.presentedViewController;
}
[window.rootViewController.view rdr_applyAppearance];
}
[[NSNotificationCenter defaultCenter] postNotificationName:RDRDidRefreshAppearanceNotificationName object:nil];
}
@end
That’s it! A nice bonus of this approach is that when the theme switch is manually triggered in a popout, that popout would be dismissed if we were to trigger a refresh using the approach Apple recommends, but now it will stay on screen leaving the user with a seamless user experience. The below GIF demonstrates this.
Switching to a new theme is now done using the following flow:
[[UIButton rdr_appearance] setBackgroundColor:[UIColor whiteColor]];
[UIView rdr_refreshAppearance];
Find the source on Github: https://github.com/datwelk/RDRAppearance