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 UIFontDescriptor 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:
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.
If we knew the slant angle of the font, it would be relatively straightforward for us to assemble a matching background shape.
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:
Internally, a Font wraps a provider, which in this case happens to be a TextStyleProvider. 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 TextStyle, such as body or largeTitle. 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 TextStyle-based Fonts.
Next, let's check out some other ways to create a Font:
This static function creates a SystemProvider. 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.
Again, this results in another provider inside the Font: NamedProvider.
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 UIFontDescriptor APIs:
Let’s compare the various configurations next to each other:
Dumping a font created this way reveals another font provider, PlatformFontProvider:
As expected, it's a straightforward wrapper around the UIFont we passed in.
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?
Font still wraps a provider, but this time it's a StaticModifierProvider that in turn wraps another provider. This pattern is similar to how ModifiedContent applies a ViewModifier to a View all over SwiftUI. Static in this case refers to the fact that ItalicModifier is stateless.
The static type of StaticModifierProvider<ItalicModifier> 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:
ModifierProvider wraps two properties, the underlying base Font and a modifier, in this case a WeightModifier 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 ModifierProvider or StaticModifierProvider 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.
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:
Next, we can implement the “root” providers, for example SystemProvider and NamedProvider:
The StaticModifierProvider holds a reference to another FontProvider:
The ItalicModifier is handed a UIFontDescriptor and adds traitItalic:
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.
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 UITraitCollection 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.