The Mystery of black italic condensed UIFont
When Weight, Width, and Italic won't play nice.
Today I encountered frustrating issue while working on custom font styling in an iOS app. I needed to create italic system fonts with custom weight and width variants - something that should be straightforward with UIKit’s font descriptor API.
Spoiler alert: it wasn’t.
The Initial Setup
I started with what seemed like a reasonable approach - an extension to create italic fonts with custom weight and width:
private extension UIFont {
@available(iOS 16.0, *)
static func prepareItalicAttributes(for weight: Weight, width: Width) -> [UIFontDescriptor.AttributeName: Any] {
return [
UIFontDescriptor.AttributeName.traits: [
UIFontDescriptor.TraitKey.weight: weight.rawValue,
UIFontDescriptor.TraitKey.width: width.rawValue,
UIFontDescriptor.TraitKey.symbolic: UIFontDescriptor.SymbolicTraits.traitItalic.rawValue
]
]
}
@available(iOS 16.0, *)
static func italicSystemFont(ofSize size: CGFloat, weight: Weight, width: Width) -> UIFont {
let attributes = prepareItalicAttributes(for: weight, width: width)
let desc = UIFont.systemFont(ofSize: size).fontDescriptor.addingAttributes(attributes)
return UIFont(descriptor: desc, size: size)
}
}
This code failed to produce an italic condensed font. If I removed the .width trait from the dictionary, it would produce a condensed font. Something was clearly conflicting so I tried mixing things up hoping it would work.
Attempt 1: Apply Symbolic Traits Separately
Maybe setting symbolic as a raw value might be conflicting with the .width trait. I tried using withSymbolicTraits(_:) as a separate step:
@available(iOS 16.0, *)
static func italicSystemFont(ofSize size: CGFloat, weight: Weight, width: Width) -> UIFont {
let attributes = prepareItalicAttributes(for: weight, width: width)
var desc = UIFont.systemFont(ofSize: size).fontDescriptor.addingAttributes(attributes)
// Apply italic trait separately
if let italicDesc = desc.withSymbolicTraits(.traitItalic) {
desc = italicDesc
}
return UIFont(descriptor: desc, size: size)
}
When I inspected the descriptors of the generated font:
// Before withSymbolicTraits
UICTFontDescriptor <0x6000027074e0> = {
NSCTFontFeatureSettingsAttribute = ( ... );
NSCTFontTraitsAttribute = {
NSCTFontProportionTrait = 0;
NSCTFontWeightTrait = "0.2300000041723251";
};
NSCTFontUIUsageAttribute = CTFontRegularUsage;
NSFontSizeAttribute = 10;
}
// After withSymbolicTraits
UICTFontDescriptor <0x600002628ea0> = {
NSCTFontFeatureSettingsAttribute = ( ... );
NSCTFontUIUsageAttribute = CTFontObliqueUsage;
NSFontNameAttribute = ".AppleSystemUIFontItalic"; // ⚠️ Concrete font name!
NSFontSizeAttribute = 10;
}
The withSymbolicTraits method was discarding my custom weight and width traits entirely and replacing them with a concrete font name.
Attempt 2: Reverse the Order
Maybe if I applied the italic trait first, then added the weight and width back, it would work?
@available(iOS 16.0, *)
static func italicSystemFont(ofSize size: CGFloat, weight: Weight, width: Width) -> UIFont {
var desc = UIFont.systemFont(ofSize: size).fontDescriptor
// Apply italic trait first
if let italicDesc = desc.withSymbolicTraits(.traitItalic) {
desc = italicDesc
}
// Now reapply weight, width, and feature settings
let attributes: [UIFontDescriptor.AttributeName: Any] = [
.traits: [
UIFontDescriptor.TraitKey.weight: weight.rawValue,
UIFontDescriptor.TraitKey.width: width.rawValue
],
.featureSettings: [ ... ]
]
desc = desc.addingAttributes(attributes)
return UIFont(descriptor: desc, size: size)
}
Nope. The italic was now being ignored. Looking at the final font descriptor:
UICTFontDescriptor <0x60000264bc60> = {
NSCTFontFeatureSettingsAttribute = ( ... );
NSCTFontUIUsageAttribute = CTFontMediumUsage; // Not italic anymore!
NSFontSizeAttribute = 10;
}
The weight/width traits were overriding the italic characteristic.
Attempt 3: Remove Concrete Font Names
What if I explicitly removed the concrete font name that withSymbolicTraits was adding and used a slant trait instead?
@available(iOS 16.0, *)
static func italicSystemFont(ofSize size: CGFloat, weight: Weight, width: Width) -> UIFont {
var desc = UIFont.systemFont(ofSize: size).fontDescriptor
if let italicDesc = desc.withSymbolicTraits(.traitItalic) {
desc = italicDesc
}
var attributes = desc.fontAttributes
attributes.removeValue(forKey: .name)
attributes[.traits] = [
UIFontDescriptor.TraitKey.weight: weight.rawValue,
UIFontDescriptor.TraitKey.width: width.rawValue,
UIFontDescriptor.TraitKey.slant: 0.07 // Explicit slant
]
desc = UIFontDescriptor(fontAttributes: attributes)
return UIFont(descriptor: desc, size: size)
}
This created a descriptor with all the right traits:
UICTFontDescriptor <0x6000026484e0> = {
NSCTFontFeatureSettingsAttribute = ( ... );
NSCTFontTraitsAttribute = {
NSCTFontProportionTrait = "-0.2000000029802322";
NSCTFontSlantTrait = "0.07000000000000001";
NSCTFontWeightTrait = "0.4000000059604645";
};
NSCTFontUIUsageAttribute = CTFontRegularUsage;
NSFontSizeAttribute = 32;
}
Perfect, right? But when I created the UIFont from this descriptor:
UICTFontDescriptor <0x6000026486c0> = {
NSCTFontFeatureSettingsAttribute = ( ... );
NSCTFontTraitsAttribute = {
NSCTFontProportionTrait = "-0.2000000029802322"; // Width survived
};
NSCTFontUIUsageAttribute = CTFontBoldItalicUsage; // iOS picked a concrete variant
NSFontSizeAttribute = 32;
}
iOS resolved descriptor to the closest concrete system font variant (CTFontBoldItalicUsage) and discarded custom weight and slant.
Attempt 4: Direct Font Names
Maybe I could bypass the system font resolution entirely by using the actual San Francisco Pro font names:
let fontName: String
switch width {
case .compressed: fontName = ".SFCompact-RegularItalic"
case .condensed: fontName = ".SFCompact-RegularItalic"
case .standard: fontName = ".SFUI-RegularItalic"
case .expanded: fontName = ".SFExpanded-RegularItalic"
default: fontName = ".SFUI-RegularItalic"
}
var desc = UIFontDescriptor(fontAttributes: [
.name: fontName,
.traits: [
UIFontDescriptor.TraitKey.weight: weight.rawValue,
],
.featureSettings: [ ... ]
])
The result?
(lldb) po UIFont(descriptor: desc, size: size).fontDescriptor
UICTFontDescriptor <0x60000261b300> = {
NSFontNameAttribute = TimesNewRomanPSMT;
NSFontSizeAttribute = 18;
}
Times New Roman, WTH…😵💫? Let me check actual font names:
static func listAvailableFonts() {
for family in UIFont.familyNames {
print("\(family)")
for names in UIFont.fontNames(forFamilyName: family) {
print("== \(names)")
}
}
}
San Francisco fonts were nowhere to be found so this was apparently futile to even attempt. That dot at the start of .AppleSystemUIFontItalic should’ve been hint enough.
Meh.
It indeed looked like I have encountered a fundamental limitation in UIKit’s font system where you cannot reliably combine custom weight, custom width, and italic traits simultaneously using UIFontDescriptor..?
On Apple Fonts page things are a bit vague. There’s…
Nine weights, including italics — and features like small caps, fractions, inferior and superior numerals, indices, arrows, and more — offer breadth and depth for precision typesetting.
and also:
SF Pro features nine weights, variable optical sizes for optimal legibility, four widths, and includes a rounded variant.
but those are two separate paragraphs. 🤷🏻♂️ So I opened FontBook app and filtered to SF Pro which yielded 45 fonts. There are all the required italic + width variants and there are width + weight variants but not a single “italic + width + weight” variant.
It seems the only way to achieve this would be to either:
- Ship custom font files with all the weight/width/italic combinations just for this part of the app which would look ridiculous.
- Accept the limitations and use only the combinations that iOS provides out of the box
- Figure out a way to artificially italicize fonts but
.slantalready failed to do that. Not sure is there another way…
I’d love to hear ideas as I’m a bit out of them, for now.