Custom Fonts in iOS Made Simple, Yet Powerful

A mix of letters and numbers used for printing.

It doesn’t take a budding iOS developer long to realize that dealing with custom fonts in a project can be a tedious struggle. Many don’t even bother considering accessibility features like Dynamic Type because the provided APIs seem so complex.

And complexity == time == money.

This quick and easy analysis of these APIs will eliminate the pain of using custom fonts and leave us with little excuse for not using dynamic type in our projects.

The Problem

Let’s look at the way we use pure, non-abstracted UIKit to initialize a glorious custom font that comes bundled with iOS.

label.font = UIFont(name: "Papyrus", size: 17)


This seems simple enough - just provide the font name and a point size. If the font doesn’t exist, you’ll get
nil. It’s a sensible API, but scattering this initializer throughout real-world projects gets very messy very quickly.

It requires a String, a very “large” type with countless possible values. It’s easy to make mistakes which will not be caught by the compiler, and can also be difficult to catch at runtime.

Solving the problem

The Swift programming language is designed with three core principles in mind: safety, speed, and expressiveness. We’re going to use Swift to create a safer, more expressive way of using custom fonts that minimizes copy-pasting and boilerplating.

Our solution will also fully support dynamic type with no added effort.

We have a small, defined set of fonts to choose from, so we need a “small” type to represent them. Enter the enum.

enum Papyrus: String {
      case regular = "Papyrus"
      case condensed = "Papyrus-Condensed"

      func of(size: CGFloat) -> UIFont? {
            return UIFont(name: rawValue, size: size)
      }
}


Here we are using that same
UIFont initializer, but we’ve abstracted it into a method on a type that is constrained to just two options.

label.font = Papyrus.regular.of(size: 17)

That’s about as simple as it gets. Font = Family → Weight → Size. All checked at compile time. Just make sure to test that your font names are correct and that you’re not getting nil.

Let's generalize it

Now you and I both know that Papyrus is the only font anyone should ever need, but designers don’t always agree. So let’s explore a solution that brings other fonts in on the fun. Enter the protocol.

protocol Font {
     func of(size: CGFloat) -> UIFont?
}

extension Font where Self: RawRepresentable, Self.RawValue == String {
     func of(size: CGFloat) -> UIFont? {
          return UIFont(name: rawValue, size: size)
     }
}

Above, we’ve created a default of(size:) method implementation for any enum with a raw type of String which conforms to the Font protocol. We now have a solution that can be used for any non-system font.

Here is all of the boilerplate for our project:

enum Papyrus: String, Font {
     case regular = "Papyrus"
     case condensed = "Papyrus-Condensed"
}

enum GillSans: String, Font {
     case regular = "GillSans"
     case italic = "GillSans-Italic"
     case semiBold = "GillSans-SemiBold"
     case ultraBold = "GillSans-UltraBold"
     case light = "GillSans-Light"
     case bold = "GillSans-Bold"
     case semiBoldItalic = "GillSans-SemiBoldItalic"
     case boldItalic = "GillSans-BoldItalic"
     case lightItalic = "GillSans-LightItalic"
}


And here is the usage:

label1.font = Papyrus.condensed.of(size: 17)
label2.font = GillSans.italic.of(size: 25)


Type safety! Expressiveness! Minimal boilerplate! 🎉

Supporting dynamic type

With iOS 11, Apple introduced a new API called UIFontMetrics to support dynamic type with custom fonts. Here’s how it works out of the box, in comparison to the system font:

// Custom Font                      ↘️⬇️↙️ Size matters!
if let font = GillSans.light.of(size: 17) {
     label1.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font)
     label2.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: font, maximumSize: 32)
}

// System Font (regular weight)
label.font = UIFont.preferredFont(forTextStyle: .body)

// System Font (other weights 😬)
let size = UIFont.preferredFont(forTextStyle: .body).pointSize
label.font = UIFont.systemFont(ofSize: size, weight: .light)


There is a major fundamental difference between system and custom fonts. Unlike the system font, we can’t just provide UIFontMetrics a text style and have the system figure out the size. We first need to create a UIFont instance and specify its size.

This is important because the font will be scaled based on that size. It acts as the “default” size, which will be used in the case that the user’s system-wide font size preference is UIContentSizeCategory.large. Again, we’ll abstract this and make it more expressive.

Let’s add another method to our Font protocol.

protocol Font {
    func of(size: CGFloat) -> UIFont?
    func of(textStyle: UIFontTextStyle, defaultSize: CGFloat, maxSize: CGFloat?) -> UIFont?
}

extension Font where Self: RawRepresentable, Self.RawValue == String {
    func of(size: CGFloat) -> UIFont? {
        return UIFont(name: rawValue, size: size)
    }
   
    func of(textStyle: UIFontTextStyle, defaultSize: CGFloat, maxSize: CGFloat? = nil) -> UIFont? {
        guard let font = of(size: defaultSize) else { return nil }
        let fontMetrics = UIFontMetrics(forTextStyle: textStyle)
       
        if let maxSize = maxSize {
            return fontMetrics.scaledFont(for: font, maximumPointSize: maxSize)
        } else {
            return fontMetrics.scaledFont(for: font
)
        }
    }
}


That’s it! We don’t even need to add any more boilerplate. We can start using dynamic type with the enums we’ve previously defined.

label1.adjustsFontForContentSizeCategory = true
label2.adjustsFontForContentSizeCategory = true

label1.font = Papyrus.condensed.of(textStyle: .headline, defaultSize: 17)
label2.font = GillSans.bold.of(textStyle: .title1, defaultSize: 28, maxSize: 38)


Setting adjustsFontForContentSizeCategory to true will allow the labels to automatically update the font size if the user changes the preferred content size category in system settings.

iOSFonts-DynamicTypeDemo

Choosing text styles and default sizes

If you’re working with a design team, it’s important that they have a basic understanding of how font scaling works across different text styles. Apple’s Human Interface Guidelines provides a table of system font size values for different text styles and user settings.

To make things a little more complicated, UIFontMetrics does not scale fonts exactly like the system font, but it’s close enough to use the table as a guide. I recommend holding close to those default values, deviating by no more than ±10%.

A Look at the Numbers

As of iOS 11, the caption1 text style has a size range of about 3.9×, while largeTitle is only 1.8×. This is because caption1 is meant for a default size of 12pt, and largeTitle is meant for 34pt. The size ranges end up making sense, if you stick to the proper default values.

iOSFonts-Charts

To make life easier

We created a library for this called Swash. All you need to do is add it to your project as a dependency and define your font enums.

What we’ve gone over is fairly easy to set up yourself, but the library does include a few extra nuggets not discussed in this post:

  • A SystemFont type to support dynamic type for different weights and further unify the font syntax in your project

  • Back support for dynamic type down to iOS 8.2, which uses system font scaling instead of UIFontMetrics, in case your deployment target is lower than iOS 11

  • A function that will print your font boilerplate for you - even less work!

  • Crashes on failed custom font initialization, to make catching mistakes easier (debug builds only)

Check it out on GitHub. Suggestions and pull requests are welcome!

Related posts