How to use custom variable fonts in UIKit
Variable fonts take order of magnitude less disk space but are not really straight-forward to use.
If you go with the solution 1 to the previously discussed issue, I strongly suggest you embed variable fonts inside your app. Taking a look at Google’s Roboto font family, it has 54 static fonts which takes up ~8MB. There are just two variable font files though, each less than 500kB.
These two files contains condensed and standard widths, 9 different weights in both normal and italic styles thus producing 54 different font variants. That is bloody awesome, if you ask me.
But how do you use them in iOS app?
First of course, you check the usage license and only then dive into technical details:
You can use them in your products & projects – print or digital, commercial or otherwise.
Now add the two files into your project and get ready for some coding.
Debug availability of font features
To figure out what fonts are available to your and how exactly they are called inside CoreText / UIKit, call this simple method anywhere in your app:
public extension UIFont {
static func listAvailableFonts() {
for family in UIFont.familyNames {
print("\(family)")
for name in UIFont.fontNames(forFamilyName: family) {
print("== \(name)")
}
}
}
You’ll see that family name is Roboto and specific fontName values are:
Roboto // font-family
== Roboto-Regular
== Roboto-Italic
== Roboto-Thin
== Roboto-ThinItalic
== Roboto-ExtraLight
== Roboto-ExtraLightItalic
...
Now, we need to figure out what OpenType’s axis tags are present for these fonts. OpenType docs are incredibly complex; it took an entire day just to get my head around all this and I feel like I know just few percents of it all.
This is how you can write out axis tag config for all 54 font variants, using familyName: "Roboto" argument:
public extension UIFont {
static func printVariationAxes(familyName family: String, size: CGFloat = 18) {
for name in UIFont.fontNames(forFamilyName: family) {
printVariationAxes(fontName: name, size: size)
}
}
/// Print all available axes in the given font
static func printVariationAxes(fontName: String, size: CGFloat = 18) {
let descriptor = UIFontDescriptor(name: fontName, size: size)
let font = UIFont(descriptor: descriptor, size: size)
if let axes = CTFontCopyVariationAxes(font as CTFont) as? [[String: Any]] {
print("\n=== Variation Axes for \(fontName) ===")
for axis in axes {
print("Axis: \(axis)")
if let identifier = axis[kCTFontVariationAxisIdentifierKey as String] as? Int {
print(" Identifier (decimal): \(identifier)")
print(" Identifier (hex): 0x\(String(identifier, radix: 16))")
}
if let name = axis[kCTFontVariationAxisNameKey as String] {
print(" Name: \(name)")
}
if let minValue = axis[kCTFontVariationAxisMinimumValueKey as String] {
print(" Min: \(minValue)")
}
if let maxValue = axis[kCTFontVariationAxisMaximumValueKey as String] {
print(" Max: \(maxValue)")
}
if let defaultValue = axis[kCTFontVariationAxisDefaultValueKey as String] {
print(" Default: \(defaultValue)")
}
print("")
}
} else {
print("No variation axes found (not a variable font)")
}
}
}
Here’s an example output for one font:
=== Variation Axes for Roboto-ThinItalic ===
Axis: ["NSCTVariationAxisIdentifier": 2003265652, "NSCTVariationAxisDefaultValue": 400, "NSCTVariationAxisName": Weight, "NSCTVariationAxisMaximumValue": 900, "NSCTVariationAxisMinimumValue": 100]
Identifier (decimal): 2003265652
Identifier (hex): 0x77676874
Name: Weight
Min: 100
Max: 900
Default: 400
Axis: ["NSCTVariationAxisName": Width, "NSCTVariationAxisIdentifier": 2003072104, "NSCTVariationAxisDefaultValue": 100, "NSCTVariationAxisMaximumValue": 100, "NSCTVariationAxisMinimumValue": 75]
Identifier (decimal): 2003072104
Identifier (hex): 0x77647468
Name: Width
Min: 75
Max: 100
Default: 100
So it has weights from 100 to 900 and widths from 75 to 100.
Couple of useful extensions
I used Claude Code to summarize all 54 of these and produce much of the ensuing enums I will write about. Right of the bat, we will need CoreText framework:
import UIKit
import CoreText
Axis tags
OpenType variable font axis tags that we need are listed below:
enum OpenTypeAxisTag: Int {
/// Width axis ('wdth') - Controls the width of glyphs
/// Range varies by font; Roboto supports 75-100
case width = 0x77647468 // 'wdth'
/// Weight axis ('wght') - Controls the weight/boldness of glyphs
/// Range varies by font; Roboto supports 100-900
case weight = 0x77676874 // 'wght'
/// Italic axis ('ital') - Binary italic on/off (0 or 1)
case italic = 0x6974616C // 'ital'
/// Slant axis ('slnt') - Oblique slant angle in degrees
case slant = 0x736C6E74 // 'slnt'
/// Optical size axis ('opsz') - Optimizes rendering for different sizes
case opticalSize = 0x6F70737A // 'opsz'
/// Grade axis ('GRAD') - Adjusts weight while preserving width
case grade = 0x47524144 // 'GRAD'
var nsNumber: NSNumber {
NSNumber(value: self.rawValue)
}
}
We will later reference these values to force CTFont match-up to appropriate font variant. I included more than the three I need. Digging-out these specific values is super convoluted thus keeping them here for possible future use.
Roboto declarations
Roboto has condensed and standard widths, per axis tag printout from before.
extension UIFont {
enum Roboto {
enum Width: CGFloat, CaseIterable {
/// Condensed width (75) - Minimum width supported by Roboto
case condensed = 75
/// Normal width (100) - Maximum width supported by Roboto (default)
case normal = 100
var value: CGFloat { rawValue }
}
}
}
We should ideally use UIFont.Width in iOS16+ and these correspond to .condensed and .standard values. Because my app at hand still supports iOS 15+ I opted for this enum.
Other font families might have more widths; per OpenType spec, allowed numerical values are between 50 and 200. I’m not 100% sure these are correct values but here’s a possible map:
@available(iOS 16, *)
private extension UIFont.Width {
var numericValue: CGFloat {
switch self {
case .compressed: return 50
case .condensed: return 75
case .standard: return 100
case .expanded: return 150
default:
// Handle unknown widths by returning standard.
return 100
}
}
}
We don’t need separate enum for 9 weights as they all correspond to UIFont.Weight values although Google uses slightly different font names:
- Thin ⇥ `.ultraLight`
- ExtraLight ⇥ `.thin`
- Light ⇥ `.light`
- Regular ⇥ `.regular`
- Medium ⇥ `.medium`
- Semibold ⇥ `.semibold`
- Bold ⇥ `.bold`
- ExtraBold ⇥ `.heavy`
- Black ⇥ `.black`
After adding few more helpful declarations:
extension UIFont.Weight: @retroactive CaseIterable {
public static var allCases: [UIFont.Weight] {
return [.ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black]
}
}
private extension UIFont.Weight {
var numericValue: CGFloat {
switch self {
case .ultraLight: return 100
case .thin: return 200
case .light: return 300
case .regular: return 400
case .medium: return 500
case .semibold: return 600
case .bold: return 700
case .heavy: return 800
case .black: return 900
default:
// Handle unknown weights by returning regular.
return 400
}
}
}
we are ready to build actual font instances.
Roboto
Ideally one would use UIFontDescriptor APIs to build the font attributes dictionary and create the font but for the life of me I couldn’t figure out how to make it work.
The approach below, using CoreText APIs did work.
extension UIFont {
static func roboto(
size: CGFloat,
width: Roboto.Width = .normal,
weight: UIFont.Weight = .regular,
italic: Bool = false
) -> UIFont {
// Use the registered font names
let baseFontName = italic ? "Roboto-Italic" : "Roboto"
// Create base font
guard let baseFont = UIFont(name: baseFontName, size: size) else {
print("Warning: Roboto font not found")
return UIFont.systemFont(ofSize: size, weight: weight)
}
// Create variation dictionary using enum
let variations: [NSNumber: NSNumber] = [
OpenTypeAxisTag.width.nsNumber: NSNumber(value: Float(width.value)),
OpenTypeAxisTag.weight.nsNumber: NSNumber(value: Float(weight.numericValue))
]
// Create new font with variations using CoreText
let variedFont = CTFontCreateCopyWithAttributes(
baseFont,
size,
nil,
CTFontDescriptorCreateWithAttributes([
kCTFontVariationAttribute: variations
] as CFDictionary)
) as UIFont // CTFont is toll-free bridged with UIFont
return variedFont
}
}
And now it’s so simple to create whatever combination you want:
let font1 = UIFont.roboto(size: 18, width: .condensed, weight: .black, italic: true)
let condensed = UIFont.roboto(size: 16, width: .condensed, weight: .regular)
let normal = UIFont.roboto(size: 16, width: .normal, weight: .regular)
You can use this debug method to build all possible UIFont instances:
extension UIFont {
enum Roboto {
static func generateAllFonts() -> [UIFont] {
var arr: [UIFont] = []
for width in Width.allCases {
for weight in UIFont.Weight.allCases {
arr.append(
UIFont.roboto(size: 18, width: width, weight: weight, italic: false)
)
}
}
for width in Width.allCases {
for weight in UIFont.Weight.allCases {
arr.append(
UIFont.roboto(size: 18, width: width, weight: weight, italic: true)
)
}
}
return arr
}
}
}
and then call it from any breakpoint to print out fontDescriptor for generated fonts:
(lldb) po UIFont.Roboto.generateAllFonts().map { $0.fontDescriptor }
Very simple way to validate your desired combination of weight+width+italic actually selected proper font variant.
This was far more complex than I expected it to be but it is what it is.