Appearance Manager

May 6, 2017

In my previous posts on fonts we looked at:

Today I want to share a utility class, AppearanceManager, that works with my UIFont extension and iOS’ appearance proxies to manage all the fonts in an app.

After Font with Style we had a system where we could update the font of a UI element with a call like this:

titleLabel.font = titleLabel.font.appFontWithSameStyle()

Unfortunately, we had to manually call that code for every UI element in every view. This also meant that we needed outlets for every UI element, even if we didn’t do anything else with it. Nearly as bad, every view controller had to override traitCollectionDidChange to update the fonts again in response to the user changing their system preferred content size.

AppearanceManager and a few small extensions on UIKit classes solve all three of these problems. I’ll discuss them in-line in this post; the final code is available here.

UIFont Tweaks

First I extended UIFont with a few more convenience methods:

extension UIFont {
    …
    
    var smallCapsVariant: UIFont {
        let smallCapsFontDescriptor = fontDescriptor.addingAttributes(
            [
                UIFontDescriptorFeatureSettingsAttribute: [
                    [UIFontFeatureTypeIdentifierKey: kLowerCaseType, UIFontFeatureSelectorIdentifierKey: kLowerCaseSmallCapsSelector],
                ]
            ]
        )
    
        let smallCapsFont = UIFont(descriptor: smallCapsFontDescriptor, size: 0)
        return smallCapsFont
    }

    class func appFont(ofSize size: CGFloat) -> UIFont {
        let font = UIFont(name: "MuseoSlab-500", size: size)
        return font ?? systemFont(ofSize: size)
    }
    
    class var navigationItemFont: UIFont {
        return appFont(ofSize: 17)
    }
    
    class var navigationBarTitleFont: UIFont {
        return UIFont.appFont(for: .headline)
    }
    
    class var buttonFont: UIFont {
        return appFont(ofSize: 17)
    }
}

Appearance­Manager­Updatable Protocol

Then I wrote a bunch of little extensions on UIKit classes. The idea is to add an API to UIViewController and UIView to allow recursively walking the entire view hierarchy of an app, updating fonts as we go.

This API has two methods, collected in a protocol:

protocol AppearanceManagerUpdatable {
    /// Perform a local update of the fonts in this object.
    func updateFontsForContentSizeChange()
    
    /// Recursively update the fonts in the children of this object.
    func recursivelyUpdateFontsForContentSize()
}

For UIViews, we want to update actual fonts. For UIViewControllers, we want to bounce the messages to the view hierarchy.

Extending UIViews

Let’s look at the UIView extensions first, since they’re easier to understand. The default extension does nothing for fonts, since not every UIView has a font, but it handles the recursion:

extension UIView: AppearanceManagerUpdatable {
    /// Default implementation does nothing.
    ///
    /// Subclasses with font attributes should override to update those fonts.
    func updateFontsForContentSizeChange() {
        // no-op
    }
    
    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all subviews, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for view in subviews {
            view.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

Next we extend the basic font-bearing UI elements to update their fonts:

extension UILabel {
    override func updateFontsForContentSizeChange() {
        font = font.appFontWithSameStyle()
    }
}

extension UITextField {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

extension UITextView {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

This brute-force updating makes some UIKit views unhappy. For example, UITabBar embeds private UILabels. You end up with a mess if you change them. So, this extension on UITabBar keeps our recursion from mucking with it:

extension UITabBar {
    override func recursivelyUpdateFontsForContentSize() {
        // Leave tab bar untouched. It doesn't like having subviews twiddled.
    }
}

In my app, I wanted to update the navigation bar title and button fonts specially, so added extensions for them as well:

extension UINavigationBar {
    override func updateFontsForContentSizeChange() {
        titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]
    }
    
    override func recursivelyUpdateFontsForContentSize() {
        // Leave navigation bar subviews untouched. It doesn't like having subviews twiddled. However, still update self.
        updateFontsForContentSizeChange()
    }
}

extension UIButton {
    override func updateFontsForContentSizeChange() {
        titleLabel?.font = titleLabel!.font.smallCapsVariant
    }
    
    override func recursivelyUpdateFontsForContentSize() {
        // Leave button subviews untouched. However, still update self.
        updateFontsForContentSizeChange()
    }
}

You might need to tweak these extensions, or add others, if your app uses different views. That’s the primary reason I haven’t tried to make a micro-framework from this technique. Extending it to cover every app would obscure the ideas.

Extending UIViewControllers

Now that we have our views ready to update, let’s extend our UIViewControllers to play along. First, UIViewController itself gets an extension to hand off updates to its view and child view controllers:

extension UIViewController: AppearanceManagerUpdatable {
    /// Default implementation calls `updateFontsForContentSizeChange()` on every view in the view hierarchy rooted at `self.viewIfLoaded`.
    func updateFontsForContentSizeChange() {
        viewIfLoaded?.recursivelyUpdateFontsForContentSize()
    }
    
    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all child view controllers, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for viewController in childViewControllers {
            viewController.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

Then collection views and table views swing a big hammer to update their views. We just have them do a full reloadData. This seems OK to me, since a user is unlikely to change their preferred content size frequently.

extension UITableViewController {
    override func updateFontsForContentSizeChange() {
        tableView.reloadData()
    }
}

extension UICollectionViewController {
    override func updateFontsForContentSizeChange() {
        collectionView?.reloadData()
    }
}

Extending Custom Classes

Having these hooks on views and view controllers is handy for custom layout. Most of my views just update automatically using this code and autolayout, but in one of my table view cells I want to update a constraint based on font size. I can do that by overriding updateFontsForContentSizeChange like so:

override func updateFontsForContentSizeChange() {
    super.updateFontsForContentSizeChange()
    
    guard let bodyFont = body.font else {
        assertionFailure("body.font not set")
        return
    }
    
    let position = bodyFont.capHeight / 2
    completionButtonCenteringConstraint.constant = round(position)
    containerView.setNeedsLayout()
}

Wiring It All Together

The AppearanceManager class triggers the automatic update process. It’s a concise 20 lines of code. The appearance manager holds a reference to the app’s root view controller, watches for content size notifications, and sends font update messages down the view controller hiearchy.

/// Controls app-wide appearance.
///
/// Responsible for updating appearance proxies and passing font update messages down the view controller and view hierarchies when the system font size changes.
class AppearanceManager: NSObject {
    private weak var rootViewController: UIViewController?
    
    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
        
        super.init()
        UIBarButtonItem.appearance().setTitleTextAttributes([NSFontAttributeName: UIFont.navigationItemFont], for: [.normal])
        UINavigationBar.appearance().titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]
        
        NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: .UIContentSizeCategoryDidChange, object: nil)
    }
    
    dynamic private func contentSizeCategoryDidChange(_ notification: Notification) {
        rootViewController?.recursivelyUpdateFontsForContentSize()
    }
}

My app delegate’s application(_:, willFinishLaunchingWithOptions:) method loads the app’s custom fonts and instantiates an AppearanceManager instance:

UIFont.loadAppFonts()
appearanceManager = AppearanceManager(rootViewController: window.rootViewController!)

In each of my UIViewController subclasses, I add a single line to the viewDidLoad() method:

override func viewDidLoad() {
    …
    recursivelyUpdateFontsForContentSize()
}

Finally, for any custom views, such as table view or collection view cells, I update fonts on load and reuse:

override func awakeFromNib() {
    …        
    recursivelyUpdateFontsForContentSize()
}

override func prepareForReuse() {
    …
    recursivelyUpdateFontsForContentSize()
}

We could use swizzling to inject these last three calls, but that seems a step too clever to me.

Next Steps

With just under 300 lines of total code, we can add custom fonts to an app. Although we developers like to complain, the conciseness of this is really a testament to the power and elegance of UIKit.

I’ve collected all the posts in this series, along with the final code and steps for adding the code to your app.

Share and enjoy.