Using A Custom Font With Dynamic Type

Using a custom font with dynamic type has always been possible but it took some effort to get it to scale for each text style as the user changed the dynamic type size. Apple introduced a new font metrics class in iOS 11 that makes it much less painful.

Last updated: Nov 10, 2022

Dynamic Type

Apple introduced dynamic type back in iOS 7 to give the user a system wide mechanism to change their preferred text size from the system settings.

Preferred text size

To support dynamic type you set labels, text fields or text views to a font returned by the UIFont class method preferredFont(forTextStyle:). The returned font, which uses the Apple San Francisco typeface, has a size and weight adjusted for the users size preference and the intended text style.

For example, to create a label with the body text style:

let label = UILabel()
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true

Notes:

  • Apple added the adjustsFontForContentSizeCategory property to UILabel, UITextField and UITextView in iOS 10. When true the font is automatically updated when the user changes their preferred font size. For iOS 9 and earlier you listen for the UIContentSizeCategoryDidChange notification and manually update the font.

  • From iOS 10 you can also get a font compatible with traits (like the size class) using preferredFont(forTextStyle:compatibileWith:).

  • There were six UIFontTextStyle values when introduced with iOS 7 (.headline, .subheadline, .body, .footnote, .caption1, caption2). iOS 9 added four more styles (.title1, .title2, .title3 and .callout). iOS 11 adds the large title style (.largeTitle).

This is how the various text styles look at extra small, large and accessibility extra-extra-extra large sizes:

Default font

Note how all of the text styles increase in size with the accessibility size. This is new in iOS 11. When the larger accessibility sizes were first introduced in iOS 7 they only applied to the .body style.

Scaling A Custom Font

Before iOS 11 to support dynamic type with a custom font you needed to decide the font details (font face and size) for each of the ten text styles and then decide how to scale those font choices for each of the twelve content size categories.

Apple publishes the font metrics they use for the San Francisco typeface in the iOS Human Interface Guidelines which acts as useful starting point when deciding how to scale each text style.

For example, the .headline text style uses a Semi-Bold face that is 17 pt at the large content size and 23 pt at the xxxLarge size.

Font Metrics

To make it easier to scale a custom font for dynamic type Apple introduced UIFontMetrics in iOS 11. To use a custom font for a given text style you first get the font metrics for that style and then use it to scale your custom font.

Let’s revisit the example of setting a label to the .body text style but with a custom font. The basic approach is this:

let font = UIFont(name: fontName, size: fontSize)
let fontMetrics = UIFontMetrics(forTextStyle: .body)
label.font = fontMetrics.scaledFont(for: font) 

You create your font with the custom font face and size. Get the font metrics for the .body style and then use scaledFont(for:) to get the font scaled for the preferred text size.

The UIFontMetrics class takes away the need to maintain a table of fonts (typeface and size) for each of the twelve content size categories. You do still need to decide on a font for each style at the default content size. This font size is then scaled by the font metrics when the user changes the content size.

A Style Dictionary

To avoid having font face names and sizes scattered through the code I ended up with a style dictionary:

typealias StyleDictionary = [StyleKey.RawValue: FontDescription]

The dictionary style key is an enum with String raw values and a case for each of the text styles:

enum StyleKey: String, Decodable {
  case largeTitle, title, title2, title3
  case headline, subheadline, body, callout
  case footnote, caption, caption2
}

The style dictionary values are a struct containing the font face name and size to use for that text style:

struct FontDescription: Decodable {
  let fontSize: CGFloat
  let fontName: String
}

Both the key and value are Decodable so that I can read the style dictionary from a plist file. Here’s how it looks for the Noteworthy typeface which Apple bundles with iOS. It has both a bold and a light face:

Noteworthy.plist style dictionary

I kept to the font sizes that Apple uses for the .large text size for each style. So, for example, I used a 17 pt Noteworthy-Bold for the .headline and a 17 pt Noteworthy-Light for the .body.

To apply the fonts I wrap the dictionary in a ScaledFont struct that you initialize with the name of the plist file (without the extension). The plist file is assumed, by default, to be in the main bundle. The font(forTextStyle:) method then returns the scaled font for each text style:

public struct ScaledFont {
  public init(fontName: String, bundle: Bundle = .main)
  public func font(forTextStyle textStyle: UIFont.TextStyle) -> UIFont
}

Check the code for the full details but here is the interesting method that looks up the font for a text style and then uses UIFontMetrics to return the scaled font. If the style dictionary does not have an entry for a text style it falls back to the Apple preferred font:

public func font(forTextStyle textStyle: UIFont.TextStyle) -> UIFont {
  guard let styleKey = StyleKey(textStyle),
    let fontDescription = styleDictionary?[styleKey.rawValue],
    let font = UIFont(name: fontDescription.fontName,
                      size: fontDescription.fontSize)
  else {
    return UIFont.preferredFont(forTextStyle: textStyle)
  }

  let fontMetrics = UIFontMetrics(forTextStyle: textStyle)
  return fontMetrics.scaledFont(for: font)
}

To use this with the Noteworthy.plist I lazily load it in the view controller:

private let fontName = "Noteworthy"

private lazy var scaledFont: ScaledFont = {
  return ScaledFont(fontName: fontName)
}()

Then when setting the font for a label, I call font(forTextStyle:):

let label = UILabel()
label.font = scaledFont.font(forTextStyle: textStyle)
label.adjustsFontForContentSizeCategory = true

As long as you are scaling the font with UIFontMetrics the adjustsFontForContentSizeCategory property still works so you do not need to worry about updating when the user changes the size. Here is how it looks using the Noteworthy font.

Noteworthy

Note: I am not sure if it is a bug or a “feature” but the .caption2 style seems to scale larger than the .caption1 style even though it uses a smaller point size at the .large size.

Using A Custom Font

You are not restricted to the typefaces included with iOS. This is NotoSans downloaded from google fonts (check the license of any fonts you download if you ship them with your application). It has regular, bold, italic and bold-italic faces. I used the italic for the subheadline and caption styles:

NotoSans

If you are downloading and adding custom font files to your project don’t forget to add them to the target and list them under the “Fonts provided by application” (UIAppFonts) key in the Info.plist:

Info.plist

If you are not sure of the font names to use you can print all available names with this code snippet:

let families = UIFont.familyNames
families.sorted().forEach {
  print("\($0)")
  let names = UIFont.fontNames(forFamilyName: $0)
  print(names)
}

Get The Code

You can get the full code for this post in my CodeExamples GitHub repository:

I’ve moved the ScaledFont type to its own Swift Package that you can find here:

Further Reading

Applying the same approach to SwiftUI:

See the WWDC 2017 session on dynamic type:

To learn more about building adaptive layouts with dynamic type get my book: