Fonts with Style

April 11, 2017

I really appreciate the preferred content size feature on iOS. This is the system setting that let’s you choose to make text smaller or larger than the default size. Apps that use Apple’s named font styles get properly sized variants of the system fonts. The API for getting a named font style is:

let font = UIFont.preferredFont(forTextStyle: .body)

Apple offers ten named styles, shown here with the default preferred content size:

All ten named preferred font styles at the preferred content size

This composite shows the same named styles at the smallest and largest preferred content sizes.

Named preferred font styles at the smallest and largest sizes

In Interface Builder, you can set the font for an object to one of the named styles using the Attributes Inspector.

Choosing a named font style with the Attributes Inspector in Interface Builder

In previous posts I showed how to embed custom fonts in your app. Suppose we want to use custom fonts but want the convenience of named font styles. Being good developers, we also want to do the right thing for our users by letting them choose their preferred content size.

I’ve been using a little extension on UIFont to meet all three objectives.

The Usual Approach

The usual way to convert between fonts in Cocoa apps is to get the fontDescriptor from a font, make a modified version of the descriptor, then construct a new font using UIFont(descriptor:, size:).

Let’s look at an example. First we get an instance of a font and print some information about it:

let font = UIFont(name: "HoeflerText-Regular", size: 17)!
print(font.fontDescriptor)
print(font)

// UICTFontDescriptor <0x600000088e80> = {
//     NSFontNameAttribute = "HoeflerText-Regular";
//     NSFontSizeAttribute = 17;
// }
//  font-family: "HoeflerText-Regular";
// font-weight: normal; font-style: normal; font-size: 17.00pt

We can then make a new descriptor by changing the font family. This replaces the "HoeflerText-Regular" font name attribute with the "Helvetica" font family attribute:

let descriptor = font.fontDescriptor.withFamily("Helvetica")
print(descriptor)

// UICTFontDescriptor <0x600000086310> = {
//     NSFontFamilyAttribute = Helvetica;
//     NSFontSizeAttribute = 17;
// }

Finally, we can make a UIFont from that descriptor:

let newFont = UIFont(descriptor: descriptor, size: 0)
print(newFont.fontDescriptor)
print(newFont)

// UICTFontDescriptor <0x600000084fb0> = {
//     NSFontNameAttribute = Helvetica;
//     NSFontSizeAttribute = 17;
// }
//  font-family: "Helvetica"; font-weight:
// normal; font-style: normal; font-size: 17.00pt

Styles Collide

But look what happens with the same code but starting with a named font style. Instead of a font name or family, the font descriptor has a key "NSCTFontUIUsageAttribute" and value UICTFontTextStyleBody representing the named font style:

let font = UIFont.preferredFont(forTextStyle: .body)
print(font.fontDescriptor)

// UICTFontDescriptor <0x60800009c890> = {
//     NSCTFontUIUsageAttribute = UICTFontTextStyleBody;
//     NSFontSizeAttribute = 17;
// }

The font itself is from the private font family .SFUIText:

print(font)

//  font-family: ".SFUIText"; font-weight:
// normal; font-style: normal; font-size: 17.00pt

Trying to change the family on the font descriptor gets us a descriptor that includes both the new family and the old named font style:

let descriptor = font.fontDescriptor.withFamily("Helvetica")
print(descriptor)

// UICTFontDescriptor <0x60800009ea00> = {
//     NSCTFontUIUsageAttribute = UICTFontTextStyleBody;
//     NSFontFamilyAttribute = Helvetica;
//     NSFontSizeAttribute = 17;
// }

Creating a font from the confused descriptor takes us right back where we started:

let newFont = UIFont(descriptor: descriptor, size: 0)
print(newFont.fontDescriptor)
print(newFont)

// UICTFontDescriptor <0x60800009c890> = {
//     NSCTFontUIUsageAttribute = UICTFontTextStyleBody;
//     NSFontSizeAttribute = 17;
// }
//  font-family: ".SFUIText"; font-weight:
// normal; font-style: normal; font-size: 17.00pt

Our Helvetica is nowhere to be found.

One Approach

The first approach that I attempted was to munge the font descriptor, like so:

  1. Take the descriptor that we retrieved from the original font,
  2. grab its fontAttributes dictionary,
  3. strip the "NSCTFontUIUsageAttribute" key, and
  4. make a new font descriptor from the results.

This would work once, but discards the named style information. Without that info, we lose the ability to update the font when the user changes their preferred content size.

A Better Way

If we’re using custom fonts, the key feature of named styles that we need to maintain is the mapping from named style to font size. As we saw above, the font descriptor for a named style only includes the style and font size. Features like weight, kerning, leading, and tracking are embedded in the private fonts.

What if we had a way to maintain the style information even when switching to a custom font? With that available, we could look up the correct font size even when the user changes their preferred content size.

Here’s an extension on UIFont that does just that. The key trick is using associated objects to piggyback style information on the font. Associated objects let us attach arbitrary data to an existing object. They can be expensive if attached to many objects and accessed frequently, but they’re a great tool when used carefully.

First we need an UnsafeRawPointer to use with the C-legacy associated object API.

private var baseStyleAssociatedObjectKey = UnsafeRawPointer(UnsafeMutableRawPointer.allocate(bytes: 1, alignedTo: 1))

Then we add a computed property to UIFont for getting and setting the font style.

extension UIFont {
    private var baseStyle: UIFontTextStyle? {

Our baseStyle property is really just a wormhole to carry the style data from one point in time to another in the lifetime of the app. Setting the property doesn’t change the font’s appearance. Our setter just needs to store the information for later retrieval.

First we check whether the font’s descriptor already includes the "NSCTFontUIUsageAttribute". This key string is bound to the global variable UIFontDescriptorTextStyleAttribute by UIKit. If the key is there, then we can use its value at retrieval time and avoid the expense of adding an associated object. If the key is not there, then we use the associate object API to associate the newValue passed to the setter with the font.

        set {
            if fontDescriptor.object(forKey: UIFontDescriptorTextStyleAttribute) is UIFontTextStyle {
                // ignore for system fonts
                return
            }

            objc_setAssociatedObject(self, baseStyleAssociatedObjectKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
        }

The getter first checks to see if the font’s descriptor includes the "NSCTFontUIUsageAttribute" key. If the key isn’t there, then we check for an associated object on the font. If neither is there, we return nil.

        get {
            if let systemStyle = fontDescriptor.object(forKey: UIFontDescriptorTextStyleAttribute) as? UIFontTextStyle {
                return systemStyle
            }
            
            if let associatedStyle = objc_getAssociatedObject(self, baseStyleAssociatedObjectKey) as? UIFontTextStyle {
                return associatedStyle
            }
            
            return nil
        }
    }    

With that property in place, we can implement a pair of functions on UIFont to convert from system named font styles into our custom ones. The first function let’s us go from a named font set on an object in a nib or storyboard to a custom font. It looks up the current base style, asserts if it’s missing, then calls a new class function on UIFont passing the baseStyle, or .body as a fallback.

    /// Returns the app-specific font with the same UIFontTextStyle as this, but sized to the current content size category.
    func appFontWithSameStyle() -> UIFont {
        assert(baseStyle != nil)
        return UIFont.appFont(for: baseStyle ?? .body)
    }

The new class function maps from named font styles to custom fonts, but uses the named style to look up the correct font size. The size will vary with named style and the user’s preferred content size.

    class func appFont(for style: UIFontTextStyle) -> UIFont {
        let preferredFont = UIFont.preferredFont(forTextStyle: style)
        let pointSize = preferredFont.pointSize        

Once we have the font size, we can switch on the named style to choose the font we want to use in our app. We fallback on the system preferred font if the font instantiation fails.

        let font: UIFont?
        
        switch style {
        case UIFontTextStyle.title1:
            font = UIFont(name: "MuseoSans-700", size: pointSize)
        case UIFontTextStyle.title2:
            font = UIFont(name: "MuseoSans-500", size: pointSize)
        case UIFontTextStyle.title3:
            font = UIFont(name: "MuseoSans-500", size: pointSize)
        case UIFontTextStyle.headline:
            font = UIFont(name: "MuseoSans-700", size: pointSize)
        case UIFontTextStyle.subheadline:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.body:
            font = UIFont(name: "MuseoSlab-500", size: pointSize)
        case UIFontTextStyle.callout:
            font = UIFont(name: "MuseoSlab-500", size: pointSize)
        case UIFontTextStyle.caption1:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.caption2:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.footnote:
            font = UIFont(name: "MuseoSlab-300", size: pointSize)
        default:
            // Other font styles are undocumented. We could use the system preferred font for the style or force our body font. The latter precludes us from using the System Font choice in Interface Builder, so let's go with the former.
            font = preferredFont
        }
        
        assert(font != nil, "Failed to load app font for style “\(style)”")
        
        let actualFont = font ?? preferredFont // fall-back to preferred font if necessary

Once we have the new font, we use our baseStyle property to pass the style information along.

        actualFont.baseStyle = style // carry the base style forward
        return actualFont
    }    
}

With this in place, we can implement viewDidLoad() in our view controllers to replace system named fonts with our own:

override func viewDidLoad() {
    super.viewDidLoad()
    
    titleLabel.font = titleLabel.font.appFontWithSameStyle()
    // … repeat with all UI elements
}

We can update when the user changes their preferred content size by overriding traitCollectionDidChange(_:).

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection) {
    super.traitCollectionDidChange(previousTraitCollection)
    
    titleLabel.font = titleLabel.font.appFontWithSameStyle()
    // … repeat with all UI element
}

In viewDidLoad the fonts will start as those set in the nib or storyboard but will end as our custom fonts. In traitCollectionDidChange the fonts will start as our custom ones and will end as our custom ones at the new preferred content size.

The font update code is exactly the same in both cases, so a simple refactoring gives us this result:

override func viewDidLoad() {
    super.viewDidLoad()
    …
    updateFonts()
}

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection) {
    super.traitCollectionDidChange(previousTraitCollection)
    updateFonts()
}

private func updateFonts() {
    titleLabel.font = titleLabel.font.appFontWithSameStyle()
    // … repeat with all UI elements
}

In a subsequent post I’ll show how we can generalize updateFonts() so we don’t have to repeat that code in our view controllers or even enumerate all our UI elements.

Until then, I hope this is helpful and encourages you to add support for preferred content sizes in your apps. My old eyes will thank you, and someday soon yours will too.

(Thanks to Paul Goracke for inspiring me to dig into this with his Xcoders talk on dynamic type.)