Dynamic Type: Scaling Custom Fonts

Dynamic Type: Scaling Custom Fonts

Hello, Iā€™m Kevin, iOS developer at Immoweb. This article will be one of a series about supporting Dynamic Type.

But before reading further, you might askā€¦

What is Dynamic Type? Why should I support it in my app?

Letā€™s first answer those questions!

If you know the answers already, you can skip them.


What is Dynamic Type?

Dynamic Type is a feature provided by Apple.

It allows you to scale your appā€™s interface (texts and images) and adapt your layouts based on the userā€™s preference.

A user can define a preferred text size to use across the system in Settings ā†’ Accessibility ā†’ Display & Text Size.

iOS ecosystem and every app should adapt according to the chosen size.

Why supporting Dynamic Type?

Because itā€™s the right thing to do, period. Thereā€™s even a saying: ā€œDo what is right, not what is easy.ā€

Need some other good reason? Well, itā€™s a win-win. It will improve the user experience of your app, and you will benefit from it, because it impacts a lot of users.

Some statistics

The World Health Organization reported that about 15% of worldwide population lives with some form of disability.

If we look at statistics from specific countries, we get 26% in the United States and 18% in France.

But this gathers all types of disabilities. What about visual disabilities? How many people are using the Dynamic Type functionality in iOS?

In the Immoweb iOS app, it has been over a year since we started recording statistics about which accessibility features are used by our users.

Immoweb is one of the biggest Belgian apps (and is mostly used in Belgium)

602,000 total users. 107,000 are using text sizes larger than default and 68,000 a smaller size which sums up to 29% of users
Dynamic Type functionality usage in Immoweb iOS app

If we look specifically at users using the Dynamic Type functionality, we can clearly see it gathers 29% of our audience. We need to make their experience with the app a great one.

Adapting to bigger sizes, for accessibility, allows us also support people who wants smaller sizes (and there are almost as many).


First approach

If you are using custom fonts in your app and want them to scale properly, this is the basic approach:

let customFont = UIFont(name: "CustomFont-Light", size: 16)!
label.font = UIFontMetrics.default.scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
  1. You use UIFontMetrics.default.scaledFont(for:) method to get a version of your font scaled to the current Dynamic Type size.
  2. You set adjustsFontForContentSizeCategory to true to allow the font to update when the size category changes.

Support for iOS 10 and earlier

For those who still need to support iOS 10 and earlier version, UIFontMetrics is not available. I redirect you to this solution.

Supporting a custom design system

In our app, we use different text styles which could be mapped to UIFont.TextStyles.

But as it often happens, in our design system, we are using slightly different styles and we donā€™t want to constraint ourselves to Apple text styles.

Hereā€™s a comparison of Apple text styles and those from Immoweb design system:

Comparison of Apple text styles versus Immoweb text styles
Comparison of Apple text styles versus Immoweb text styles

You can see we are using more styles than Apple and depending on the style, we use one of two fonts: Montserrat or PT Sans.

Use our custom text styles

So we decided to go with our own custom text styles:

struct TextStyle {
	let size: CGFloat
	let emphasis: Emphasis
	let name: Name
	
	init(size: CGFloat, emphasis: Emphasis = .none, name: Name = .ptSans) {
		self.size = size
		self.emphasis = emphasis
		self.name = name
	}
}

extension TextStyle {
	typealias Name = String

	enum Emphasis {
		case none
		case bold
		case italic

		var symbolicTraits: UIFontDescriptor.SymbolicTraits? {
			switch self {
			case .none:
				return nil
			case .bold:
				return .traitBold
			case .italic:
				return .traitItalic
			}
		}
	}
}

extension TextStyle.Name {
	static let montserrat: TextStyle.Name = "Montserrat-Regular"
	static let ptSans: TextStyle.Name = "PTSans-Regular"
}

So far:

  • We have created a TextStyle struct that will be used to define each of our text styles.
  • We created an init defaulting to no emphasis and using our main font, PT Sans.
  • We created an Emphasis enum to specify if the text style should be in bold, italic or stay regular. We chose to not use directly UIFontDescriptor.SymbolicTraits and all its values because we donā€™t need them (so far). So we created this enum with only the cases we need, to be more clean.

Defining our text styles

Now we can add our styles like so:

extension TextStyle {
	// MARK: - Headings
	
	static let h1 = TextStyle(size: 24, emphasis: .bold, name: .montserrat)
	static let h2 = TextStyle(size: 20, emphasis: .bold, name: .montserrat)
	static let h3 = TextStyle(size: 18, emphasis: .bold, name: .montserrat)
	static let h4 = TextStyle(size: 16, emphasis: .bold, name: .montserrat)
	static let h5 = TextStyle(size: 14, emphasis: .bold, name: .montserrat)
	static let h6 = TextStyle(size: 13, emphasis: .bold, name: .montserrat)

	// MARK: - Text buttons
	static func buttonLarge(emphasis: Emphasis) -> TextStyle {
		TextStyle(size: 16, emphasis: emphasis)
	}

	static func buttonSmall(emphasis: Emphasis) -> TextStyle {
		TextStyle(size: 14, emphasis: emphasis)
	}

	// MARK: - Texts
	static let text18 = TextStyle(size: 18)
	static let text16 = TextStyle(size: 16)
	static let text13 = TextStyle(size: 13)
	static let text14 = TextStyle(size: 14)

	// MARK: - Text highlights
	static let textHighlight24 = TextStyle(size: 24, emphasis: .bold)
	static let textHighlight20 = TextStyle(size: 20, emphasis: .bold)
}

We decided to make TextStyle a struct and not an enum so we can easily extend it to create new text styles if needed. And itā€™s usually the case. Youā€™ll end up with a few exceptions for certain specific UIā€™s.

Now letā€™s see how weā€™ll use those styles weā€™ve just createdā€¦

Extend UIFont

We are going to create fonts with our new TextStyle by extending UIFont class with 2 static functions:

extension UIFont {
	/// Returns a font matching `style`
	static func font(style: <ModuleName>.TextStyle) -> UIFont {
		var descriptor = UIFontDescriptor(name: style.name, size: style.size)

		if let symbolicTraits = style.emphasis.symbolicTraits {
			descriptor = descriptor.withSymbolicTraits(symbolicTraits)!
		}

		return UIFont(descriptor: descriptor, size: style.size)
	}

	/// Returns a font matching `style` scaled to the current Content Size Category.
	static func scaledFont(style: <ModuleName>.TextStyle) -> UIFont {
		UIFontMetrics.default.scaledFont(for: font(style: style))
	}
}

Here we use the UIFontDescription class to be able to specify symbolicTraits.

We can now use this to get the font from one of our TextStyle:

label.font = .scaledFont(style: .h1)
label.adjustsFontForContentSizeCategory = true

It is clean šŸ‘Œ

We can even call UIFont.font(style: .h1) if we need a fixed sized font (like for tab bar and navigation bar texts).

Now we can see that thereā€™s still something we could improve:

Each time we use label.font = UIFont.scaledFont(style: ā€¦) we have to also add the statement label.adjustsFontForContentSizeCategory = true which is a bit redundant.

What we would do, ideally, is to have an API that looks like this:

label.apply(textStyle: ā€¦)

And it would, by default, use the font scaled to the current content size category and adapt to content size category changes.

Letā€™s see how we can implement thatā€¦

Create a common protocol

We want to add a apply(textStyle:) method to UILabel, UITextField, UITextView and UIButton. For that weā€™ll create a common protocol. Letā€™s call it TextStyleAdjustable:

protocol TextStyleAdjustable: UIContentSizeCategoryAdjusting {
	func setFont(_ font: UIFont)

	@discardableResult
	func apply(textStyle: TextStyle) -> Self
}

extension TextStyleAdjustable {
	@discardableResult
	func apply(textStyle: TextStyle) -> Self {
		setFont(.scaledFont(style: textStyle))
		adjustsFontForContentSizeCategory = true

		return self
	}
}

Our protocol inherits from UIContentSizeCategoryAdjusting so we can have access to the variable adjustsFontForContentSizeCategory.

Now we can implement this protocol:

extension UILabel: TextStyleAdjustable {
	func setFont(_ font: UIFont) {
		self.font = font
	}
}

extension UITextView: TextStyleAdjustable {
	func setFont(_ font: UIFont) {
		self.font = font
	}
}

extension UITextField: TextStyleAdjustable {
	func setFont(_ font: UIFont) {
		self.font = font
	}
}

extension UIButton: TextStyleAdjustable {
	func setFont(_ font: UIFont) {
		titleLabel?.font = font
	}

	public var adjustsFontForContentSizeCategory: Bool {
		get {
			titleLabel?.adjustsFontForContentSizeCategory ?? false
		}
		set {
			titleLabel?.adjustsFontForContentSizeCategory = newValue
		}
	}
}

Note that UILabel, UITextField, UITextView already implement UIContentSizeCategoryAdjusting protocol so we donā€™t need to implement adjustsFontForContentSizeCategory variable for them, but we do for UIButton.

We can use our new API:

label.apply(textStyle: .h1)
textField.apply(textStyle: .text16)
textView.apply(textStyle: .text16)
button.apply(textStyle: .buttonLarge(emphasis: .bold))

And all texts in our app will be correctly sized and adapt automatically, all of that with custom fonts šŸ™Œ

Add a SwiftLint check

If you are using SwiftLint I recommend adding a custom rule to make sure we donā€™t use unwanted APIā€™s by mistake, such as:

  • UIFont.preferredFont(forTextStyle:)
  • UIFont.systemFont(ofSize:)
  • UIFont.boldSystemFont(ofSize:)
  • etc.
# https://github.com/realm/SwiftLint#defining-custom-rules
custom_rules:
  use_scaled_fonts:
    name: "Use scaled fonts: UIFont.scaledFont(style:)"
    regex: '\.(?!scaled)[a-zA-Z]*[fF]ont\('
    severity: error

This way weā€™ll avoid using the wrong APIs by mistake. You can change severity: error to warningto start if needed.

You can still disable the rule in specific cases by adding //swiftlint:disable:next use_scaled_fonts on the line before the call to one of the prohibited APIā€™s we listed above. An example of a case we would do that is when setting a font for the tab bar, which should never scale.

Final code

You can have the final code here.

Conclusion

We saw that the Dynamic Type feature is used by many users (29% of Immoweb iOS audience) and it is important to support it in our apps.

As it is usually the case, our design systems include more text styles than Apple is offering by default with UIFont.TextStyle, for this reason we created our own TextStyle struct to support them.

We want scalable texts to be the default behaviour in our apps, so we created APIā€™s to enforce that: UIFont.scaledFont(style:) and <textField/textView/label/button>.apply(textStyle:).

To go further, in the next articles weā€™ll see how we can update our layout, scale our images dynamically to the Dynamic Type and moreā€¦

I hope you liked this article!

See you soon šŸ‘‹

Resources