SwiftUI under the Hood: Fonts

Rich and expressive typography is a cornerstone of Apple's UI design yet with the current version of SwiftUI, some of the most powerful APIs are hidden from us. Let's see what it would take to get them back.

Like many other aspects of building interfaces for Apple's platforms, SwiftUI also dramatically changes the way we deal with fonts.

Where previously UIFont and UIFont­Descriptor were our main interfaces to obtaining a font, SwiftUI replaces them with a single Font struct.

Using the font(_:) modifier, you can set a Font for a view tree and have it propagate through the environment. A Text view will then pick up the relevant font from the environment, resolve it and use it to display the text to the user.

However, Font is highly opaque and while we can convert a UIFont to a Font, currently no equivalent API exists to go the other direction.

This limitation makes achieving certain effects in a purely SwiftUI app very hard. For example, let's say we want to highlight a piece of text by putting a pink background behind it. A naive approach might look like so:

Text("Hello!")
  .lineLimit(1)
  .background(
    Color.pink
  )
  .font(.custom("Avenir", size: 32))

This looks alright if the current font is roman, but if it has any kind of slant, most commonly found in oblique or italic fonts, the rectangular shape of the background clashes with the parallelogram outline suggested by the text.

Text("Hello!")
  .lineLimit(1)
  .background(
    Color.pink
  )
  .font(.custom("Avenir", size: 32).italic())

If we knew the slant angle of the font, it would be relatively straightforward for us to assemble a matching background shape.

This angle is exposed on UIFont through toll-free bridging and CTFont­Get­Slant­Angle. In SwiftUI no such property is exposed and again, there is no API to convert from a Font to a UIFont.

Because Font in SwiftUI is a late-binding token, resolving the Font is done as – you guessed it – late as possible and may require additional information stored outside the Font, such as the current content size category or accessibility settings such as the legibility weight.

That said, as long as we’re thorough enough, we should be able to replicate most if not all of this resolution behavior. For the time being, SwiftUI is expected to live side by side with UIKit so we can assume that SwiftUI views will respond to the user’s preferences in an identical manner.

We can also assume that at least some of the data needed to construct a UIFont is stored inside the Font struct. If we can find a way to read this data, we might be able to create a matching UIFont using the “conventional” UIKit APIs and the user preferences available in the environment.

So let's take a peek behind the curtain and see what kind of data we can extract.

Welcome to Dumpsville, Population: Font

Using the dump method that is part of Swift’s reflection APIs, we take a look at what is actually stored inside a Font struct:

dump(Font.body)
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.TextStyleProvider
      - style: SwiftUI.Font.TextStyle.body
      - design: SwiftUI.Font.Design.default
      - weight: nil

Internally, a Font wraps a provider, which in this case happens to be a Text­Style­Provider. Another dump confirms that Font.body is in fact a shorthand for Font.system(.body, design: .default).

For most purposes, Apple suggests using a semantic Text­Style, such as body or large­Title. This enables the system to appropriately set the metrics of the fonts based on user preferences such as Dynamic Type and the language of the text. It's no surprise that Font's API is steering us in this direction by devoting most of its surface area to Text­Style-based Fonts.

Next, let's check out some other ways to create a Font:

dump(Font.system(size: 17, weight: .regular, design: .default))
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.SystemProvider
      - size: 17.0
      ▿ weight: SwiftUI.Font.Weight
        - value: 0.0
      - design: SwiftUI.Font.Design.default

This static function creates a System­Provider. We still get a system font – currently a variant of San Francisco or New York – but we opt out of Dynamic Type completely and pin the font size at whatever value we put in.

Next, we have multiple overloads of custom that allow us to look up a font based on its name. We can either scale this font proportionally to the system font at any particular text style or pin it to a fixed font size.

dump(Font.custom("Helvetica", size: 17, relativeTo: .body))
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.NamedProvider
      - name: "Helvetica"
      - size: 17.0
      ▿ textStyle: Optional(SwiftUI.Font.TextStyle.body)
        - some: SwiftUI.Font.TextStyle.body

Again, this results in another provider inside the Font: Named­Provider.

Last but not least, Font provides an initializer that takes a CTFont (or a toll-free bridged UIFont or NSFont) as its only parameter. This serves as an escape hatch and allows us to invoke any of the existing pre-SwiftUI font resolution APIs.

For example, if you want to use Inter's variable font features to freely chose slant and weight, you would have to use relatively low-level UIFont­Descriptor APIs:

extension Font {
    static func inter(size: CGFloat, slant: CGFloat = 0, weight: CGFloat = 0) -> Font {
        let descriptor = UIFontDescriptor(fontAttributes: [
            .name: "Inter",
            kCTFontVariationAttribute as UIFontDescriptor.AttributeName: [
                /* 'wght' */ 0x77676874: weight,
                /* 'slnt' */ 0x736c6e74: slant
            ]
        ])

return Font(UIFont(descriptor: descriptor, size: size)) } }

Let’s compare the various configurations next to each other:

let weights: [CGFloat] = Array(stride(from: 100, through: 900, by: 150))
let slants: [CGFloat] = Array(stride(from: 0, through: -10, by: -10 / 6))

VStack { ForEach(slants, id: \.self) { slant in HStack { ForEach(weights, id: \.self) { weight in Text("Hi") .font(.inter(size: 32, slant: slant, weight: weight)) } } } }

Dumping a font created this way reveals another font provider, Platform­Font­Provider:

dump(Font.inter(size: 40, slant: -5, weight: 300))
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.PlatformFontProvider
      - font:  font-family: "Inter"; font-weight: normal; font-style: italic; font-size: 40.00pt #1
        - super: UIFont
          - super: NSObject

As expected, it's a straightforward wrapper around the UIFont we passed in.

Modifying Fonts

This covers creating fonts, but a fair share of the Font APIs deal with modifying a font handed to our view from elsewhere in the environment.

What happens when we call italic on a Font?

dump(Font.body.italic())
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox> #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.StaticModifierProvider
      ▿ base: SwiftUI.Font
        ▿ provider: SwiftUI.FontBox #1
          - super: SwiftUI.AnyFontBox
          ▿ base: SwiftUI.Font.TextStyleProvider
            - style: SwiftUI.Font.TextStyle.body
            - design: SwiftUI.Font.Design.default
            - weight: nil

Font still wraps a provider, but this time it's a Static­Modifier­Provider that in turn wraps another provider. This pattern is similar to how Modified­Content applies a View­Modifier to a View all over SwiftUI. Static in this case refers to the fact that Italic­Modifier is stateless.

The static type of Static­Modifier­Provider<Italic­Modifier> fully describes what operation needs to be performed to the underlying base font: selecting the italic variant of a given font.

We can compare this to a font modifier that takes an argument:

dump(Font.body.weight(.medium))
▿ SwiftUI.Font
  ▿ provider: SwiftUI.FontBox> #0
    - super: SwiftUI.AnyFontBox
    ▿ base: SwiftUI.Font.ModifierProvider
      ▿ base: SwiftUI.Font
        ▿ provider: SwiftUI.FontBox #1
          - super: SwiftUI.AnyFontBox
          ▿ base: SwiftUI.Font.TextStyleProvider
            - style: SwiftUI.Font.TextStyle.body
            - design: SwiftUI.Font.Design.default
            - weight: nil
      ▿ modifier: SwiftUI.Font.WeightModifier
        ▿ weight: SwiftUI.Font.Weight
          - value: 0.23

Modifier­Provider wraps two properties, the underlying base Font and a modifier, in this case a Weight­Modifier that stores the Weight variant to be selected.

Given what we now know about the internals of Font, we can start to ponder how conversion to a UIFont might be implemented.

One possible approach is recursive: the font asks its provider to provide a UIFont. In the case of Modifier­Provider or Static­Modifier­Provider it would recursively ask its own base provider for a font (or font descriptor), then make the necessary modifications.

Eventually, we will hit one of the “root” providers and the recursion will stop.

Recreating Fonts

Based on this hunch of how the Font might be implemented, we can attempt to sketch out a font resolution API of our own:

First, we define protocols for providers and modifiers:

protocol FontProvider {
    func fontDescriptor(with traitCollection: UITraitCollection?) -> UIFontDescriptor
}

extension FontProvider { func font(with traitCollection: UITraitCollection?) -> UIFont { UIFont(descriptor: fontDescriptor(with: traitCollection), size: 0) } }
protocol FontModifier { func modify(_ fontDescriptor: inout UIFontDescriptor) }
protocol StaticFontModifier: FontModifier { init() }

Next, we can implement the “root” providers, for example System­Provider and Named­Provider:

struct SystemProvider: FontProvider {
    var size: CGFloat

var design: UIFontDescriptor.SystemDesign
var weight: UIFont.Weight?
func fontDescriptor(with traitCollection: UITraitCollection?) -> UIFontDescriptor { UIFont .preferredFont(forTextStyle: .body, compatibleWith: traitCollection) .fontDescriptor .withDesign(design)! .addingAttributes([ .size: size ]) } }
struct NamedProvider: FontProvider { var name: String
var size: CGFloat
var textStyle: UIFont.TextStyle?
func fontDescriptor(with traitCollection: UITraitCollection?) -> UIFontDescriptor { if let textStyle = textStyle { let metrics = UIFontMetrics(forTextStyle: textStyle ?? .body)
return UIFontDescriptor(fontAttributes: [ .family: name, .size: metrics.scaledValue(for: size, compatibleWith: traitCollection) ]) } else { return UIFontDescriptor(fontAttributes: [ .family: name, .size: size ]) } } }

The Static­Modifier­Provider holds a reference to another Font­Provider:

struct StaticModifierProvider<M: StaticFontModifier>: FontProvider {
    var base: FontProvider

func fontDescriptor(with traitCollection: UITraitCollection?) -> UIFontDescriptor { var descriptor = base.fontDescriptor(with: traitCollection)
M().modify(&descriptor)
return descriptor } }

The Italic­Modifier is handed a UIFont­Descriptor and adds trait­Italic:

struct ItalicModifier: StaticFontModifier {
    init() {}

func modify(_ fontDescriptor: inout UIFontDescriptor) { fontDescriptor = fontDescriptor.withSymbolicTraits(.traitItalic) ?? fontDescriptor } }

With the providers in place, we now need to initialize them with the data we saw in our dumps earlier.

Through reflection, we can attempt to access the provider property of a Font and match its type against one of the types we've discovered earlier. Based on the type, we then read the relevant properties such as text style or font weight and create a parallel hierarchy of our own structs.

func resolveFont(_ font: Font) -> FontProvider? {
    let mirror = Mirror(reflecting: font)

guard let provider = mirror.descendant("provider", "base") else { return nil }
return resolveFontProvider(provider) }
func resolveFontProvider(_ provider: Any) -> FontProvider? { let mirror = Mirror(reflecting: provider)
switch String(describing: type(of: provider)) { case "StaticModifierProvider<ItalicModifier>": guard let base = mirror.descendant("base", "provider", "base") else { return nil }
return resolveFontProvider(base).map(StaticModifierProvider<ItalicModifier>.init) case "SystemProvider": guard let size = mirror.descendant("size") as? CGFloat, let design = mirror.descendant("design") as? Font.Design else { return nil }
let weight = mirror.descendant("weight") as? Font.Weight
return SystemProvider(size: size, design: design.uiSystemDesign, weight: weight?.uiFontWeight) case "NamedProvider": guard let name = mirror.descendant("name") as? String, let size = mirror.descendant("size") as? CGFloat else { return nil }
let textStyle = mirror.descendant("textStyle") as? Font.TextStyle
return NamedProvider(name: name, size: size, textStyle: textStyle?.uiTextStyle) default: // Not exhaustive, more providers need to be handled here. return nil } }

Now we have the tools we need to turn a Font into a UIFont, blunt as they may be.

In order for Dynamic Type and other accessibility features to continue working, we would need to create a UITrait­Collection that reflects the current state of the environment.

Once we have the UIFont resolved, we can read its slant angle and with a little bit of trigonometry, assemble a background shape that matches the text.

struct BackdropText: View {
    @Environment(\.font)
    var font

@Environment(\.sizeCategory) var sizeCategory
var uiFont: UIFont { // TODO: Not exhaustive, more environment parameters need to be taken into account here. let traits = UITraitCollection(traitsFrom: [ .init(preferredContentSizeCategory: environment.sizeCategory.uiContentSizeCategory) ])
resolveFontProvider(font: font ?? .body)?.font(with: traits) ?? UIFont() }
var body: some View { // Relative to the top of the view let baselineY = uiFont.lineHeight + uiFont.descender
// in rad let slant = abs(uiFont.slant) * .pi / 180
Text("Hello World!") .lineLimit(1) .background( Rectangle() .transform( CGAffineTransform(shearX: -tan(slant), y: 0) .translatedBy(x: baselineY * sin(slant), y: 0) ) .fill(Color.pink) ) } }
extension UIFont { var slant: CGFloat { CTFontGetSlantAngle(self) } }