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 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.
This angle is exposed on UIFont
through toll-free bridging and CTFontGetSlantAngle
. 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.
Font
Welcome to Dumpsville, Population:
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 Font
s.
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.
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
?
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.
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:
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.