iosdev

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.