Fonts with Style
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:
This composite shows the same named styles at the smallest and largest preferred content sizes.
In Interface Builder, you can set the font for an object to one of the named styles using the Attributes Inspector.
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:
- Take the descriptor that we retrieved from the original font,
- grab its
fontAttributes
dictionary, - strip the
"NSCTFontUIUsageAttribute"
key, and - 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.)