Use Dynamic Type with Custom Fonts in iOS

Daniel Hu
10 min readJul 15, 2022
Photo by Markus Spiske on Unsplash

Difficulty Level: Beginner | Easy | Normal | Challenging

The content of this article was developed using Xcode 13.4 and Swift 5.

Introduction of Dynamic Type

Apple introduced the Dynamic Type feature in iOS 7, which allows developers to change the text size according to the user’s settings. More than 40% of iOS users have chosen to change their font size to a non-default one (including me). While many of them, like all of our parents, have made their device font size enormous, many others chose to set their font size smaller so that more content can fit on the screen.

Users can change text size in the Settings app -> Accessibility -> Display & Text Size -> Larger Text. By default, there are 6 visible levels and the slider is in the middle (level 3); if you turn on the Larger Accessibility Sizes switch, it will add 5 more larger levels (making 11 in total) for you to choose from.

Settings -> Accessibility -> Display & Text Size -> Larger Text (iOS 15)

How to support Dynamic Type

The easiest way for a developer to support Dynamic Type is to use the system predefined text styles, such as large title, headline, body, and more. This is really nice and easy, and it requires almost zero effort.

System predefines text styles

By using the system predefined text styles, iOS will use San Francisco, the system default font, and take the default size of the chosen text style, scale it with the text size level the user set in Settings to calculate the final font size, and finally render the text onto the screen.

While in some simple apps, using the system default fonts may suffice; however, in most large scale apps, clients will want to use some custom fonts to make their apps look different and give the users a more personalized experience.

Fortunately for us developers, Apple introduced UIFontMetrics in iOS 11 to help us achieve that. In this article you will see how we can do it step by step.

Support Dynamic Type with System Text Style

In Interface Builder

There are 2 simple steps to implement Dynamic Type for any text-displaying UI widget:

  1. select a predefined text style from the Font dropdown menu
  2. check the Automatically Adjusts Font checkbox

Usually you would want to check this checkbox when you add support for Dynamic Type, but if you forgot to check it, your app UI will not immediately update after the user changes the text size scale in Settings; the UI will reflect the changes after a relaunch or a reload.

In Code

To do the same thing programmatically, you can

Or, if you want to avoid typing out the long property name adjustsFontForContentSizeCategory each time, you can write an extension method to help set font on any text displaying UIView:

Then you can set font on any UIView with one line of code:

button.setFont(.preferredFont(forTextStyle: .largeTitle))

Use Text Style with Custom Weight & Size

Each predefined text style comes with a default weight and size.

For example, for largeTitle text style, the default weight is regular and the default size is 34.0pt; for headline text style, the default weight is semiBold and the default size is 17.0pt.

Did I make these values up? No. You can easily check them out yourself in the Xcode Debug View Hierarchy.

Debug View Hierarchy

But what if I want to have a bolder, larger title, while still keep using the system text styles so I can get the Dynamic Type support for free?

Well, thanks to the UIFontMetrics Apple introduced in iOS 11, we can now easily do it!

Let’s mimic Apple’s UIFont.preferredFont(forTextStyle style: UIFont.TextStyle) -> UIFont function, and make a more powerful one by ourselves:

So there are 4 steps to create a scaled font (Dynamic Type):

  1. Create a UIFontMetrics object with the passed in TextStyle.
  2. Get the UIFontDescriptor object for the preferred font of the text style, as we will use the .pointSize property as the default size.
  3. Create a system font with the passed in Weight, and the passed in size (if there is one) or the default size we computed in step 2.
  4. Use the UIFontMetrics object to scale the font and return.

So now I can use this method on my largeTitleLabel:

largeTitleLabel.font = .preferredFont(forTextStyle: .largeTitle, weight: .bold, size: 60)

And now check the result in the Debug View Hierarchy; it worked!

So what’s the point of using text style if I can customize size?

So with the new extension method we created, we can basically create a font using any text style with any default size. For example, I can create a largeTitle text style font with 20.0pt default font size and also create a headline text style font with 20.0pt default font size. Then what is the difference between these two fonts?

Let’s test to find out!

So I set the fonts for the 2 labels

largeTitleLabel.font = .preferredFont(forTextStyle: .largeTitle, weight: .medium, size: 20)headlineLabel.font = .preferredFont(forTextStyle: .headline, weight: .medium, size: 20)

and inspect them in View Hierarchy

Sure enough, they are both 20.0pt large and there is absolutely no difference between them at this point. But if now I go to the Settings app -> Accessibility, change the device text size to level 6 (the right most), and go back to my test app, we will notice the difference:

Ironically, the largeTitle text style font is now smaller than headline(23.0pt vs. 26.0pt). This may seem weird at first, but if you really think about it, it makes perfect sense.

As I mentioned earlier, the system default font size for largeTitle text style is 34.0pt, and 17.0pt for headline. These are the sizes to display under the default text size scale settings (when the slider is in the center; let’s call it 100% scale). When the user moves the slider to the right and increases the text size scale to a bigger value (let’s say 200%), the UI would become very broken if iOS just double all text sizes on the screen: the large title would become way too large, and the small text like captions, footnotes may not be big enough. That’s why iOS uses different scale factor for different text styles. Usually the larger the system default font size a text style has, the smaller its scale factor will be.

In our test case, largeTitle text style has a smaller scale factor than headline, and they have the same default font size since we manually set them to 20.0pt. They look the same at 100% scale, but when we change to 200% scale, the headline label with the larger scale factor grows faster, hence becomes bigger than the largeTitle label.

So what can we learn from this?

Well, we should NOT use text styles only for their sizes. For example, you shouldn’t use largeTitle text style for your body text just because you want the main content to be bigger. This is very similar to HTML tags in web design: you should not use <h1> over <p> for your body text just because you want them to look bigger.

Use the text styles for their semantic meaning!

That is, you should use headline for your headline, use subhead for your subhead line, use body for your main content text. If you want them look bigger or smaller, pass in a custom default size!

Use Dynamic Type with custom fonts

So now that our preferredFont method supports Dynamic Type with custom weight and default font size, let’s add a final touch to make it support custom fonts too!

I will not go into details about how to add custom fonts to your app, as it is pretty straightforward and is well documented by Apple. You can find how to do it in the Apple documentation.

I’ve followed Apple documentation and added two free custom fonts I found on Google Fonts: Roboto Mono & Playfair Display.

I also added the code snippet Apple has provided to get the list of available fonts in my AppDelegate.swift file, after app did finish launching.

for family in UIFont.familyNames.sorted() {    
let names = UIFont.fontNames(forFamilyName: family)
print("Family: \(family) Font names: \(names)")
}

Now if I run my app, I will find my console is bombed by all kinds of font family names.

To only print out the custom fonts we care about, I added some filtering to the code above, as well as Swift preprocessor flags #if DEBUG to only print these names when debugging.

Now if I run the app again, I am able to see a much cleaner console log with only the font families I care.

Cool! Now let’s modify our preferredFont class function and add a fontName parameter so it can support custom fonts.

class func preferredFont(forTextStyle style: UIFont.TextStyle, fontName: String? = nil, weight: Weight = .regular, size: CGFloat? = nil) -> UIFont {// 1let metrics = UIFontMetrics(forTextStyle: style)// 2let descriptor = preferredFont(forTextStyle: style).fontDescriptorlet defaultSize = descriptor.pointSize// 3let fontToScale: UIFontif let fontName = fontName,let font = UIFont(name: fontName, size: size ?? defaultSize){  fontToScale = font} else {  fontToScale = UIFont.systemFont(ofSize: size ?? defaultSize, weight: weight)}// 4return metrics.scaledFont(for: fontToScale)}

And to use the new method with a custom font:

label.font = .preferredFont(forTextStyle: .largeTitle, fontName: "PlayfairDisplay-ExtraBoldItalic", weight: .ultraLight, size: 40)

And the result is:

If you slide the text size slider in Settings, you will notice that it does grow/shrink according to your settings. So overall it is working, not bad!

But I can’t help noticing 2 defects in this approach:

  1. The long font name string I had to type out each time to use a custom font: this is painful and error-prone.
  2. The weight parameter is not respected: even though we passed in .ultraLight, the final font is actually extra bold italic, just as the font name suggested.

So I came up a neater way to manage all my custom fonts: by using a enum!

So I created an enum CustomFont and listed all my custom fonts as cases. I also created a Weight enum which contains all the possible weight variants for my custom fonts. You should be able to find all the available weight variant names in your console log.

* I used PascalCase for CustomFont and Weight cases, which is not the Swift standard camelCase, but it is the easiest way to take the advantage of Swift’s enum implicit assigned raw values feature.

Then in the font() function:

  1. I joined the font raw value with the weight raw value using a “-”; the end result will look like this: “PlayfairDisplay-BoldItalic” — which is the font name we need.
  2. For font size, I use either the passed in size or a computed default size for the passed in text style.
  3. Use the font name and size from step 1 and 2 to create a UIFont. If it failed to do so (spelling mistake; or usage of a weight variant that doesn’t exist for the font family you chose), use the regular system font instead.
  4. Return the scaled font.

Now let’s put this function to use:

label.font = CustomFont.playfairDisplay.font(textStyle: .largeTitle, weight: .MediumItalic, defaultSize: nil)

And we got the result we expected (the font size is set to largeTitle with a default font size of 34.0pt as we passed in nil):

* You should be careful with the weight variant you are using because not all of them are supported by all font families. For example, Playfair Display does not have “Thin” and Roboto Mono does not have “Black”.
If you really want to be precise and only use available weight variants for a custom font, you can do so by defining each font’s specific weight variants in an enum.

Then the usage will change a little bit and becomes this:

label.font = CustomFont.playfairDisplay(weight: .MediumItalic).font(textStyle: .largeTitle, defaultSize: 30)

Final Touch: make your life a little easier

So now we have it, a CustomFont enum that helps us easily access any custom font of any size, any weight, and in any text style. The last piece I would like to add, are some tips and a few convenient methods that enable you to use custom fonts more easily.

Define commonly used fonts as static vars

Let’s say in your app, you use the same font for all your large titles on every view controller and another font for all your headlines; instead of creating a font every time you use it, you can define some static vars to simplify your usage.

enum CustomFont: CustomStringConvertible {  static let largeTitle = playfairDisplay(weight: .Bold).font(textStyle: .largeTitle, defaultSize: 40)  static let headline = robotoMono(weight: .MediumItalic).font(textStyle: .headline)  ...
}

Then inside your view controller, you can easily access these common fonts like this:

largeTitleLabel.setFont(CustomFont.largeTitle)headlineLabel.setFont(CustomFont.headline)

One line to set them all

If you have many text display views in a single screen, setting them one by one is a tedious job, and can take many lines of code. To help you set fonts/texts faster, here are a few take-away extensions and variadic functions.

The usages are like this:

typealias CT = CustomFontsetFontsOnViews(([largeTitleLabel], CT.largeTitle),                ([headlineLabel, headlineLabel2], CT.headline),                ([bodyTextView], CT.body))
setTextsOnViews((largeTitleLabel, "My Large Title"), (headlineLabel, "My cool headline"), (bodyTextView, "My body content"))

That’s all for today! Here is the GitHub repo for the sample project used in this article.

Until next time, happy coding with Dynamic Type!

References

--

--