37

Styling your app using custom UIAppearance properties

 5 years ago
source link: https://www.tuicool.com/articles/hit/eENZBj2
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

UIAppearance is analogous to CSS for UIKit, while being compatible with both Interface Builder and traditional styling in code, without sacrificing performance. It’s a way of declaratively assigning UI style values to your views, without needing to manually tweak settings throughout your codebase. This makes it easy to define your app’s visual style centrally, which makes maintenance simpler when changes are necessary.

In this post, I’ll talk about how you can define your own custom UIAppearance properties in your views, allowing you to declaratively style your app giving you more flexibility and reusability.

Note:I began writing the draft of this article back in March 2013, and I’m just now getting back to it in April 2019. This not only shows you how busy I’ve been over the past few years, but also how stable UIAppearance is, since I’m able to largely continue from where I left off.

Hypothetical scenario

Let’s start this post with a hypothetical scenario: your product manager asks you to add a custom design for your user’s avatars. Your designer gives you mockups showing what it should look like, and you get straight to work building this feature. You create some custom UIView subclasses to implement the exact design, and you push your code up to version control. You have a sense of accomplishment, you feel happy, and move onto the next user-story.

After user-testing feedback the designer asks for a few changes; colors, padding, and other minor tweaks are needed. That’s okay, right? It’s just a few UIEdgeInsets changes in some classes, changes in a UITableView datasource, and maybe changing some values in UIColor (oh, but your designer gave you the values in hex, so you manually convert them to floats) and you change those values in a few files. You’re back on track! You commit your code, and try to remember where you were before you got sidetracked.

But wait! The designer then tells you to make the paddings and avatar sizes larger on the user’s main account page. You hadn’t planned for this! These settings are hard-coded within the custom class, and now you need to change a bunch of code to get this view to adapt its constants based on where it’s being used.

Does this sound familiar?

Coding for flexibility

With agile development methodologies it’s more important than ever to build flexibility into your code.

Having constants and style code sprinkled throughout an app can make it difficult to make bulk changes in a complex code-base, and can result in consistency problems when changing color or padding values; it’s easy to forget or miss one value somewhere in the app, turning a simple “Make the label a different shade of blue” into a several day fiasco.

At the end of the day, your code has to live somewhere. Wouldn’t it be better if you could style your application flexibly with little to no overhead?

UIAppearance crash course

Many sites out there do an excellent job of describing UIAppearance from a practical standpoint, so I won’t attempt to reproduce what others have written. Instead, I’ll give a quick recap if you need a refresher, and will refer you to NSHipster’s article on UIAppearance .

UIAppearance is a very efficient styling system, similar to CSS, that applies styling settings to views based on their position within the view hierarchy. It allows supporting view properties to be configured ahead of time, and those values are automatically set on your views when they get added to their parent (technically speaking, when a view is added to a window).

It supports properties with some basic primitive value types (e.g. CGFloat, CGRect, UIEdgeInsets, etc.), as well as some object types (e.g. UIImage, UIColor, etc.). Many UIKit views have properties that support UIAppearance, but they must have the UI_APPEARANCE_SELECTOR attribute added to it.

Note:Swift gets a free pass by not needing the attribute associated with it, but that just means you have to make sure your property accessor methods are compatible .

How UIAppearance works

UIKit uses protocols to define which classes are capable of being styled ( UIAppearance ) and which classes can be containers of those views ( UIAppearanceContainer ). Both protocols can be used when constraining properties to specific views in your hierarchy.

Under the covers, UIAppearance works by setting property values on a proxy of the class in question. This proxy records the selectors invoked, and their corresponding values. For example, if the following appearance setting was made:

UIButton.appearance.contentEdgeInsets = edges;

The selector setContentEdgeInsets: is recorded, along with the value given to it. When a button is then added to the view hierarchy, that invocation is replayed, and the settings get applied automatically. This frees you to focus on the structure of your views, leaving the implementation details of how they’re styled to be handled just-in-time.

For more fine-grained control of which views should be styled, instead of using the appearance class method, you can use appearanceWhenContainedInInstancesOfClasses: to scope the value to views contained in a particular place in the view hierarchy.

For example, if I only wanted buttons contained within a particular view controller to have custom content edge insets, I could use the following code:

[UIButton appearanceWhenContainedInInstancesOfClasses:@[ MyViewController.class ].contentEdgeInsets = edges;

Since UIAppearance isn’t magic, or specific to Apple’s own classes, we can use this knowledge to our advantage to create our own UIAppearance properties in our custom views.

Decide what you want to style

The first step of course is to determine what it is that you’d like to style.  It helps if you’ve already cleanly abstracted your code and compartmentalized your subviews as custom classes. In general, if I need to encode a value as a constant or hard-coded value (e.g. padding, UIEdgeInsets, colors, offsets, etc.) those are candidates for assigning separate properties.

As a practical example, let’s discuss a view where we’ll have a reusable “Show More” pill button that can be used throughout our app featuring rounded corners, a thin border, with a specific background and text color.

@interface ShowMoreView : UIView

@property (nonatomic, readonly) UILabel *textLabel;

@end

Under normal circumstances, this view will be configured either in code, or through a nib. For simplicity sake, let’s assume the view is configured in code.

@implementation ShowMoreView

- (instancetype)initWithFrame:(CGRect)frame {
    self = [super initWithFrame:frame];
    if (self) {
        _textLabel = UILabel.new;
        [self addSubview:_textLabel];

        // Add constraints

        self.layer.borderColor = UIColor.blueColor.CGColor;
        self.layer.borderWidth = 1.0;
        self.layer.cornerRadius = 10.0;
    }
    return self;
}

@end

As you can see from the example, the styling of the view (colors, spacing, images, etc) are hard-coded within that view. What if we wanted to adapt the styles in a more general way? Or if we wanted the colors and margins to differ based on where the view lives in the view hierarchy?

Add appearance properties

Typically I like placing my appearance properties in a category or extension, to separate the logic and make it clear which methods are specifically used for the purposes of UIAppearance.

@interface ShowMoreView (UIAppearance)

@property (nonatomic) UIColor *borderColor UI_APPEARANCE_SELECTOR;
@property (nonatomic) CGFloat borderWidth UI_APPEARANCE_SELECTOR;

@end

In this category, each of the hard-coded appearance values is defined as a property with the appropriate type, along with the UI_APPEARANCE_SELECTOR attribute.

Note:When working in Swift, the UI_APPEARANCE_SELECTOR attribute is unnecessary, and doesn’t even exist, though you do need to make sure your setter/getter for your properties are compatible .

Once you have defined the properties, those need to be hooked up to the underlying views they should control.

@implementation ShowMoreView (UIAppearance)

- (UIColor *)borderColor {
    return (UIColor*)self.layer.borderColor;
}

- (void)setBorderColor:(UIColor *)borderColor {
    self.layer.borderColor = borderColor.CGColor;
}

- (CGFloat)borderWidth {
    return self.layer.borderWidth;
}

- (void)setBorderWidth:(CGFloat)borderWidth {
    self.layer.borderWidth = borderWidth;
}

@end

We can then quite simply set the default values somewhere in our application by assigning them to the appearance proxy for our class.

ShowMoreView.appearance.borderColor = UIColor.blueColor;
ShowMoreView.appearance.borderWidth = 1.0;

Interestingly enough, since those properties aren’t unique to this particular view (after all, layer settings apply to any view), we can move these properties to a category on UIView instead!

@interface UIView (UIAppearanceBorders)

@property (nonatomic) UIColor *borderColor UI_APPEARANCE_SELECTOR;
@property (nonatomic) CGFloat borderWidth UI_APPEARANCE_SELECTOR;

@end

With this change in our appearance selector properties, our above ShowMoreView.appearance style settings can remain the same; this means that only instances of that class will be assigned those appearance values, and all other views will remain the same. This allows us to reuse those same properties to style views with borders in other areas of our application.

Adding properties to UIView using Swift extensions

Objective-C categories aren’t the only way to add custom UIAppearance properties to system classes. We can use Swift extensions to add custom properties even easier than in Objective-C.

extension UIView {
    @objc public var borderColor : UIColor? {
        set(color) { layer.borderColor = color?.cgColor }
        get { return layer.borderColor as? UIColor? ?? nil }
    }
    
    @objc public var borderWidth : CGFloat {
        set(width) { layer.borderWidth = width }
        get { return layer.borderWidth }
    }
}

It’s important to make the properties public , and use the @objc keyword so the properties can be seen by the UIAppearance subsystem in UIKit, but overall this code is much cleaner than its Objective-C counterpart.

Value-backed Properties

There are times where setting values directly to properties on underlying views isn’t enough; the appearance values may need to be used in different places, or at different times, and may need to be preserved elsewhere. This is where traditional value-backed properties come into play.

Continuing on our above example, let’s add support for rounded corners on this button. And in the interests of making this as flexible as possible, let’s add support for perfectly rounded sides, or a fixed corner radius.

@objc public class ShowMoreView: UIView {

    @objc public enum CornerStyle : Int {
        case none
        case fixedRadius
        case rounded
    }

    @objc public dynamic var cornerStyle: CornerStyle = .none {
        didSet(style) { updateCorners() }
    }
    
    @objc public dynamic var cornerRadius: CGFloat = 0 {
        didSet(radius) { updateCorners() }
    }
    
    override public func layoutSubviews() {
        super.layoutSubviews()
    
        if (cornerStyle == .rounded) {
            updateCorners()
        }
    }
    
    private func updateCorners() {
        var radius = cornerRadius
        switch cornerStyle {
        case .fixedRadius: break
        case .rounded:
            radius = frame.size.height / 2
        case .none:
            radius = 0
        }
        layer.cornerRadius = radius;
    }
}

Defining an enum with different corner style “modes” gives us the flexibility to define a variable corner radius to ensure the sides of the view are as perfectly rounded as possible, giving the view a “pill” shape.

These values need to be stored in value-backed properties because they need to be consulted not only at the time that they’re set, but later in layoutSubviews when the view’s size changes.

State-derived complex properties

There are times where a value is dependent on the state of the view, for example another string or enum value. UIAppearance allows for this, and is in fact commonly used within UIKit. For example, UIButton allows you to define the titles and colors for the image based on its state .

- (nullable UIColor *)titleColorForState:(UIControlState)state;
- (void)setTitleColor:(nullable UIColor *)color 
             forState:(UIControlState)state UI_APPEARANCE_SELECTOR;

- (nullable UIColor *)titleShadowColorForState:(UIControlState)state;
- (void)setTitleShadowColor:(nullable UIColor *)color 
                   forState:(UIControlState)state UI_APPEARANCE_SELECTOR;

- (nullable UIImage *)backgroundImageForState:(UIControlState)state;
- (void)setBackgroundImage:(nullable UIImage *)image 
                  forState:(UIControlState)state UI_APPEARANCE_SELECTOR;

This is convenient because it allows your UI to adapt to user input, simplifying your display logic because the conditions under which certain colors or paddings are applied can be declared outside of the class itself. Doing this has helped my projects tremendously in cleaning up messy spaghetti code.

r26nMnE.png!web Custom view with complex properties

As a practical example, we’ll build a custom view that will show a user’s avatar, with a badge overlaid on it to show their “rank” within the app. The view will have two UIImageView objects to contain the avatar and the status badge. The borders can be styled using the previous properties already created earlier in this post (e.g. borderColor , borderWidth ) so no extra custom properties will be needed for those. But I’d like to avoid copy-and-pasting the image names for the badges throughout my app. Instead I’d like to utilize an enum value to control which badge image is shown for each state, so I don’t have to explicitly refer to those images by name throughout my codebase.

Let’s jump straight to the code, and we can step through it.

@objc public class AvatarView: UIView {

    @objc public enum Rank : Int {
        case none = 0
        case adventurer
        case knight
        case king
    }
    
    public var imageView : UIImageView!
    public var rank : Rank = .none {
        didSet {
            badgeView.image = badgeImage(for: rank)
        }
    }
    
    @objc public dynamic func badgeImage(for rank: Rank) -> UIImage? {
        return badgeImages[rank]
    }
    
    @objc public dynamic func setBadgeImage(_ image: UIImage?, for rank: Rank) {
        badgeImages[rank] = image
        
        if rank == self.rank {
            badgeView.image = image
        }
    }
    
    // Private storage for the image view, and assigned images
    private var badgeView : UIImageView!
    private var badgeImages : [Rank: UIImage] = [:]
    
    override public func layoutSubviews() {
        super.layoutSubviews()
        
        // Make the image views circular
        for view in subviews {
            view.layer.cornerRadius = view.bounds.size.width / 2
        }
    }
}

The UIAppearance getter/setter badgeImage and setBadgeImage utilize a private dictionary to store the images passed down through the UIAppearance proxy. And in the didSet function on the rank property, the badge’s image view is assigned the appropriate image using the getter.

Beyond a little bit of book-keeping to ensure the views are circular, the class is straight-forward.

It’s important to note that the class, and relevant UIAppearance properties, must be public and annotated with the @objc attribute, and the property setters must have the dynamic attribute as well.

From here, all we need to do is add the relevant appearance settings to our app delegate:

Test application showcasing the way a view can be updated using complex UIAppearance properties.

Assigning values to these selectors

The handy part of UIApplication is that these values can be assigned in one place. The UIApplicationDelegate ‘s didFinishLaunching method is usually a good place for this, but you can place it almost anywhere.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        ShowMoreView.appearance().cornerStyle = .rounded
        ShowMoreView.appearance().borderWidth = 1
        ShowMoreView.appearance().borderColor = UIColor.blue

        UIImageView.appearance(whenContainedInInstancesOf: [AvatarView.self]).borderWidth = 1
        UIImageView.appearance(whenContainedInInstancesOf: [AvatarView.self]).borderColor = UIColor.darkGray
        AvatarView.appearance().setBadgeImage(UIImage(named: "Crown"), for: .king)
        AvatarView.appearance().setBadgeImage(UIImage(named: "Knight"), for: .knight)
        AvatarView.appearance().setBadgeImage(UIImage(named: "Adventurer"), for: .adventurer)

        return true
    }
}

Let’s step through the previous code example, to discuss what’s happening.

Simple assignment

ShowMoreView.appearance().cornerStyle = .rounded
ShowMoreView.appearance().borderWidth = 1
ShowMoreView.appearance().borderColor = UIColor.blue

This is a simple assignment of a property value that will be applied to all instances of the ShowMoreView class. The same goes for the other two lines that sets the borderWidth and borderColor .

Assignment based on view hierarchy

UIImageView.appearance(whenContainedInInstancesOf: [AvatarView.self]).borderWidth = 1
UIImageView.appearance(whenContainedInInstancesOf: [AvatarView.self]).borderColor = UIColor.darkGray

This example actually uses some of the same properties as the ones used in our previous example, except in this case we’re only changing the borders for UIImageView instances that are children of the AvatarView class.

Assignment based on view state

AvatarView.appearance().setBadgeImage(UIImage(named: "Crown"), for: .king)
AvatarView.appearance().setBadgeImage(UIImage(named: "Knight"), for: .knight)
AvatarView.appearance().setBadgeImage(UIImage(named: "Adventurer"), for: .adventurer)

These are assigning badge images to the AvatarView class, when the state is a given value. This technique can be used for many other values from UI control state to trait collection type.

Mixing & Matching with Interface Builder

One of the benefits of separating your design into separate properties is easy integration with Interface Builder. UIAppearance can be used to set the baseline for your views, and is great for applications that build their UI entirely in code.

But if your application uses Interface Builder, even for part of your UI, you can simply mark your properties with @IBInspectable (and the enclosing class with @IBDesignable ) and those property values are now exposed in Interface Builder.

@IBDesignable extension UIView {
    @objc @IBInspectable public var borderColor: UIColor? { get set }
    @objc @IBInspectable public var borderWidth: CGFloat { get set }
}
Custom style properties automatically show up in Interface Builder

With only a tiny annotation to your view classes, you’ve unlocked the flexibility of your style properties for rapid prototyping and design right within Interface Builder.

Summary

At the end of the day, your code has to go somewhere. Instead of sprinkling your color and font choices throughout your application, you can wrap your view’s styling code in custom properties to simplify your style choices, centralize your styles in one convenient place, and even provide direct access to those styles within Interface Builder.

Your future self will thank you when widespread style changes can be made with a single line of code.

What are your thoughts on this approach? Is there anything specific you’d like to know more about when using UIAppearance? Leave a note in the comments and let me know!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK