Scaling Custom SwiftUI Fonts With Dynamic Type

We’ve previously seen how to make a custom font scale with dynamic type when using UIKit. How do you do the same with SwiftUI?

Font Metrics

I’m a big fan of dynamic type to give the user control over their preferred text size. If you’re using custom fonts, Apple added the font metrics class in iOS 11 to make it less painful to support dynamic type.

You choose the font, size and weight to use for each text style you use and the font metrics give you a scaled version based on the user’s preferred text size. The only downside is that you can end up with font metrics scattered throughout your view code.

An approach that worked well for me with UIKit was to collect the font metrics into a style dictionary. See using a custom font with dynamic type for the details. Let’s see how to adapt that for SwiftUI.

Note: Before you switch to a custom font don’t overlook how much you can tweak the appearance of the system fonts.

SwiftUI Fonts Recap

Let’s recap the ways you can set the font of a SwiftUI text view to use dynamic type. First using the .font view modifier with a text style:

// Use a dynamic type text style
Text("Title 1")
  .font(.title)

We can tweak the design of the text style. For example, switching to a serif font:

// Dynamic type text style with a different design
Text("Title 1")
  .font(Font.system(.title, design: .serif))

Scaling Custom Fonts (iOS 13)

Starting in iOS 13, Apple added the custom(_:size:) method to create a custom font that scales:

// Noteworthy font, 28 point
Text("Title 1")
  .font(Font.custom("Noteworthy", size: 28.0))

Unfortunately, this only uses a scale factor derived from the body text style. If you need to support iOS 13 and want to create fonts that scale correctly for any text style you’ll have to fall back to UIKit. For example, to create a scaleable title font:

let titleFont: UIFont = {
  let font = UIFont(name: "Noteworthy", size: 28.0)!
  let fontMetrics = UIFontMetrics(forTextStyle: .title1)
  return fontMetrics.scaledFont(for: font)
}()

For SwiftUI, we need a Font not a UIFont. Luckily we can create a SwiftUI Font from a core text CTFont which itself bridges to both UIFont and NSFont. In other words, we can create a SwiftUI Font from a UIFont:

Text("Title 1")
  .font(Font(titleFont))

Note: Apple includes the Noteworthy font with iOS. If you’re adding custom font files to your app target don’t forget to list them in your Info.plist file under the “Fonts provided by application” key.

Scaling A Custom SwiftUI Font (iOS 14)

Once you can require iOS 14 things get easier. Apple added the custom(_:size:relativeTo:) method in iOS 14. This allows you to specify the text style used for scaling:

Text("Title 1")
  .font(Font.custom("Noteworthy", size: 28.0, relativeTo: .title))

This saves us some work but can soon get out of hand:

VStack(alignment: .leading, spacing: 8.0) {
  Text("Title 1")
  .font(Font.custom("Noteworthy-Light", size: 28.0, relativeTo: .title))
  Text("Title 2")
  .font(Font.custom("Noteworthy-Light", size: 22.0, relativeTo: .title2))
  Text("Title 3")
  .font(Font.custom("Noteworthy-Light", size: 20.0, relativeTo: .title3))
  Text("Headline")
  .font(Font.custom("Noteworthy-Bold", size: 17.0, relativeTo: .headline))
  Text("Subheadline")
  .font(Font.custom("Noteworthy-Light", size: 15.0, relativeTo: .subheadline))
  Text("Body")
  .font(Font.custom("Noteworthy-Light", size: 17.0, relativeTo: .body))
  Text("Callout")
  .font(Font.custom("Noteworthy-Light", size: 16.0, relativeTo: .callout))
  Text("Footnote")
  .font(Font.custom("Noteworthy-Light", size: 13.0, relativeTo: .footnote))
  Text("Caption 1")
  .font(Font.custom("Noteworthy-Light", size: 12.0, relativeTo: .caption))
  Text("Caption 2")
  .font(Font.custom("Noteworthy-Light", size: 11.0, relativeTo: .caption2))
}

This is exactly the problem I had with using a custom font with dynamic type in UIKit. I don’t want those font metrics sprinkled throughout my view code. It’s hard to be consistent and a problem if I want to make changes to the metrics or even change fonts later.

Revisiting ScaledFont

The approach I used with UIKit was to create a style dictionary that collected the font metrics for a font in one place:

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
}

Everything is Decodable so I can read a style dictionary from a plist (or JSON) file. Here’s how that looks for the Noteworthy font example from earlier:

Noteworthy plist file

I wrapped the dictionary in a ScaledFont type that takes care of reading and decoding the plist file. For UIKit, I also have a method that 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 details. We’ll use the custom(_:size:relativeTo:) method we saw earlier to create the method that returns us a SwiftUI Font:

func font(forTextStyle textStyle: Font.TextStyle) -> Font {
  guard let styleKey = StyleKey(textStyle),
    let fontDescription = styleDictionary?[styleKey.rawValue] else {
    return Font.system(textStyle)
  }

  return Font.custom(fontDescription.fontName,
    size: fontDescription.fontSize,
    relativeTo: textStyle)
}

This is close to the UIKit version. It looks up the entry in the style dictionary and returns the scaled font for the text style. If the dictionary entry is missing it falls back to the system font. This requires iOS 14. To support iOS 13 we can fallback to the UIKit method, but I’ll omit that here.

Using The SwiftUI Environment

App-wide theme settings like the font seem a good fit for the SwiftUI environment. I want to be able to create a ScaledFont when my app launches and pass it down from the root view hierarchy:

ContentView()
  .environment(\.scaledFont, ScaledFont(fontName: "Noteworthy"))

I’ve covered how to create SwiftUI Custom Environment Values. Here’s a quick recap:

Create the environment key with a default value

private struct ScaledFontKey: EnvironmentKey {
  static var defaultValue = ScaledFont(fontName: "Default")
}

Extend the environment to add our property

public extension EnvironmentValues {
  var scaledFont: ScaledFont {
    get { self[ScaledFontKey.self] }
    set { self[ScaledFontKey.self] = newValue }
  }
}

Creating A View Modifier

To make it easy to apply the custom scaled font we need a SwiftUI custom view modifier that calls our font(forTextStyle:) method using the scaled font stored in the environment:

private struct ScaledFontModifier: ViewModifier {
  @Environment(\.scaledFont) var scaledFont
  let textStyle: Font.TextStyle

  func body(content: Content) -> some View {
    content
      .font(scaledFont.font(forTextStyle: textStyle))
  }
}

We can then create a convenience extension on View for our modifier:

public extension View {
  func scaledFont(_ textStyle: Font.TextStyle = .body) -> some View {
    return modifier(ScaledFontModifier(textStyle: textStyle))
  }
}

Our SwiftUI text style sample view after applying our view modifier:

VStack(alignment: .leading, spacing: 8.0) {
  Text("Title 1")
    .scaledFont(.title)
  Text("Title 2")
    .scaledFont(.title2)
  Text("Title 3")
    .scaledFont(.title3)
  Text("Headline")
    .scaledFont(.headline)
  Text("Subheadline")
    .scaledFont(.subheadline)
  Text("Body")
    .scaledFont(.body)
  Text("Callout")
    .scaledFont(.callout)
  Text("Footnote")
    .scaledFont(.footnote)
  Text("Caption 1")
    .scaledFont(.caption)
  Text("Caption 2")
    .scaledFont(.caption2)
}

The appearance at XXL content size using Noteworthy:

Noteworthy Text Styles

Get The Code

I made ScaledFont available as a standalone Swift Package:

You can find UIKit and SwiftUI sample projects in my GitHub code examples repository: