Nat! bio photo

Nat!

Senior Mull

Twitter Github Twitch

UIAppearance: Magic == Pain

This shit is so obscure, that I needed to a lot of research to get a coherent view of what's happening in my head.

The problem with UIAppearance geometry values

Let's assume that I have a property padding that defines the size of an inner border of my UIView subclass. It's merely a decorative element and has a default value of 2 pixels on each side.

-(void) initWithCoder:(NSCoder *) coder
{
   self = [super initWithCoder:coder];
   if( self)
      self->_padding = UIEdgeInsetsMake( 2, 2, 2, 2);
    return( self);
}
// and the same for initWithFrame:

I want this to be skinnable so I make it an UI_APPEARANCE_SELECTOR.

@property(nonatomic) UIEdgeInsets padding   UI_APPEARANCE_SELECTOR;

In my UIView subclass I want to host a contentView of some random size (code not shown). How do I calculate the frame.size of my view ? I get the frame of the contentView and add the padding. Simple.

   UIEdgeInsetsInsetRect   insets;
   
   insets        = [self padding];
   insets.top    = - insets.top;
   insets.left   = - insets.left;
   insets.bottom = - insets.bottom;
   insets.right  = - insets.right;
   frame         = UIEdgeInsetsInsetRect( [_contentView frame], 
                                          insets);

Will this work ? It depends. It depends on WHEN I call it.

Here's where the research comes in. This is the order in which a typical iOS program calls some of the key classes instance methods. This is not a stack trace, just the chronological order. The -[UIView setPadding:] call is the UIAppearance setting the value in the UIView:

-[UIWindow initWithFrame:]
-[UIViewController initWithNibName:]
-[UIWindow setRootViewController:]
-[UIWindow makeKeyAndVisible]
-[UIView initWithCoder:]
-[UIView awakeFromNib]
-[UIViewController viewDidLoad]
-[UIViewController viewWillAppear:]
-[UIView willMoveToWindow:]
+[UIView requiresConstraintBasedLayout]
-[UIView setPadding:]
-[UIView didMoveToWindow]
-[UIView layoutSubviews]
-[UIView drawRect:]
-[UIViewController viewDidAppear:]

It is important to notice, that the UIAppearance value is available only during and after -[UIView didMoveToWindow]. It is NOT available during -[UIViewController viewDidLoad] or -[UIViewController viewWillAppear:]. And -[UIViewController viewDidAppear:] is obviously too late. As the constraint code is running earlier, auto layout code would not help either.

Rule #1: Do not access UI_APPEARANCE_SELECTOR properties if -[UIView window] returns nil

Now you may think: "hey, do the code in -[UIView layoutSubviews]". But as the name indicates, that method should layout the subviews of my UIView and not change its own frame. That should have been set during the superview's layoutSubviews method. Otherwise the superview could be surprised and misconfigured. In the end, it's really the job of the UIViewController to set my UIView's frame.

But the UIViewController doesn't have a chance anymore to do this.

The clever 90% Solution

How can this be fixed ? Well it would be nice, if the UIView subclass offered a method like +frameForContentRect:, so the view's frame can be calculated in advance, during -[UIViewController viewDidLoad] given the frame of the future contentView:

+ (CGRect) frameForContentRect:(CGRect) rect
{
   id <UIAppearance>       appearance;
   UIEdgeInsetsInsetRect   insets;

   appearance    = [self appearance];
   insets        = [appearance padding];
   insets.top    = - insets.top;
   insets.left   = - insets.left;
   insets.bottom = - insets.bottom;
   insets.right  = - insets.right;
   return( UIEdgeInsetsInsetRect( rect, insets));
}

Nice, but it doesn't work really well, if no one is setting the UIAppearance value, and we fall back to the default value of the instance.

Solution ? Don't set the default value in the instance but instead preset the UIAppearance with it at +load time and delete the instance default value code from initWithCoder: (and initWithFrame:)

+ (void) load
{
  [[self appearance] setPadding:UIEdgeInsetsMake( 2.0, 2.0, 2.0, 2.0)]);
}

Nicey, but does it solve the problem ?

Not always, because the value of padding could also be set by user code on an individual instance. And then the UIAppearance value won't be used. Obviously there is no chance for knowing that in the custom +frameForContentRect: method, since it's a class method.

Rule #2: Set UI_APPEARANCE_SELECTORs default values in +load

I think using +load to set default UIAppearance values is still a good and rather clean method, but for the problem at hand it's not the whole solution. It's pretty clean, because you can be sure that the default is set earlier than -[id <UIApplicationDelegate> application:didFinishLaunchingWithOptions:], which one would expect the application coder to set his UIAppearance values.

The painful 100% Solution

The only good solution I have come up so far is this: Setup your view hierarchy like you just don't care, possibly in -[UIViewController viewDidLoad]. The real work has to be done delayed. For that you have to override your subclass -[UIView didMoveToWindow] method and call a custom method on the UIViewController. Clumsy, but magic always comes with a price.

Rule #3: Geometry UI_APPEARANCE_SELECTORs need special attention