Saving a font to UserDefaults

(Here using macOS 13, Xcode 10, Swift 4.2, but NSFontDescriptor also exists on iOS, just substitute UIFont).

The recommended way of saving a font is not to do it (a bit flippant, sorry) – but in WWDC session 223 from 2013 (Using Fonts with TextKit), the recommendation is specifically to a) not cherry-pick attributes (in other words, not save only font name and size) and b) not save NSFont, but rather save NSFontDescriptor.

NSFontDescriptor is an easily overlooked class, but the documentation is quite clear that

_The symbolic traits supersede the existing NSFontTraitMask type used by NSFontManager_.
so we should be using it preferentially. Especially on iOS, you can do a lot of weird and wonderful things with it, including cascading preferences for the font you want to use, language-specific settings, etc etc.

There is nothing to be gained by saving NSFont. If the font encoded (respectively the font corresponding to the FontDescriptor) is not installed and active on the user’s system, using it will fail (as it should). In that respect, NSFont and NSFontDescriptor behave exactly the same; but NSFontDescriptor is smaller, legal to save (licences can be tricky), and, see above, Apple strongly reccommends it.

0) Basic saving, assuming you have a font called ‘font’:

let fontDescriptor = font.fontDescriptor
let descriptorData = NSKeyedArchiver.archivedData(withRootObject: fontDescriptor)
userDefaults.set(descriptorData, forKey: "descriptorData")

Reading:

if let descriptorData = userDefaults.data(forKey: "descriptorData"){
if let fontDescriptor = NSKeyedUnarchiver.unarchiveObject(with: descriptorData) as? NSFontDescriptor {
let myFont = NSFont(descriptor: fontDescriptor, size: 0)
}
}

Setting the size to ‘0’ means that it takes the size from the descriptor, otherwise you’re overriding it.

1) This example takes the font from an NSTextField and saves it to userDefaults (easiest for testing); you can also encode the descriptorData in a Codable environment using encode(to Encoder) and init(from Decoder) – what is important is that you turn font information into Data and Data into a Font.

@IBOutlet var descriptorInput: NSTextField!

This is a rabbit hole I fell into so you don’t have to; you’d think that getting the font from a text field would be straightforward, particularly since NSTextField has a font attribute, but it’s not as straightforward as that.

The font of an NSTextField is the font that you, the developer, set. You can do this in code (by assigning a font to the font property); you can do this in the Storyboard, and most of the time, developers leave it alone and the text field uses the default system font.

When you give users the option to change the font, they change the attributes of the attributedString displayed in the TextField; the font attribute remains consistent, so if you save the font property and load it, suddenly we’re back with the system font.

If it’s important to save the attributed string with all changes, you need to look to NSAttributedString for ways of converting it into Data (or saving it as .rtf); this post does not deal with this option.

Here, we will assume that we save the attributes of the first letter and use that font/colour combination for saving and to set the attributes after reloading.

func saveDescriptor() {

var range = NSRange()

let attributes =
descriptorInput.attributedStringValue.attributes(at: 0, effectiveRange: &range)
if let font = attributes[.font] as? NSFont {
let fontDescriptor = font.fontDescriptor

let descriptorData = NSKeyedArchiver.archivedData(withRootObject: fontDescriptor)
UserDefaults.standard.set(descriptorData, forKey: "descriptorData")
}

}

@IBOutlet var descriptorOutput: NSTextField!

func loadDescriptor() {
if let descriptorData = UserDefaults.standard.data(forKey: "descriptorData"){
if let fontDescriptor = NSKeyedUnarchiver.unarchiveObject(with: descriptorData) as? NSFontDescriptor {
if let myFont = NSFont(descriptor: fontDescriptor, size: 0) {

let descriptorString = NSAttributedString(string: "Using Descriptor", attributes: [NSAttributedString.Key.font: myFont])

descriptorOutput.attributedStringValue = descriptorString
} else {
descriptorOutput.stringValue = "cannot create font from descriptor"
}

}
}
}

This does not save the font colour; if you want to save a default text colour, you still need to do this separately. You convert NSColor to Data with NSRootArchiver, and decode it with

let reconstructedColor = NSKeyedUnarchiver.unarchiveObject(with: fontColorData) as? NSColor

and add the following to the array of attributes on the attributedString:

NSAttributedString.Key.foregroundColor : reconstructedColor

The art of good citizenship

What follows is the difference between a working app (one that you are still developing, that you use for yourself) and a polished app, ready to go into the world/AppStore. These are small improvements, but they make a big difference to how the app feels to users.

For most circumstances, it’s good practice to treat the user’s font choice as ‘this is the font I want to use now AND in the future, so you don’t just set an attributed string, but the textField’s .font and .textColor attributes as well. If you create an attributed string, set the textField’s attributedString value to it, and the user deletes the whole string, the textField will revert to its default font/colour if it loses focus, which will confuse users and might irritate them.

If you are building a document-based app, you may have grown used to the automatic undo that Cocoa gives you for NSTextView. NSTextField always has automatic undo of typing as long as it retains focus; once it loses focus, your changes are discarded, so if you want to specifically retain changes of the TextField’s font, you need to invoke them.

You can see this in the image accompanying this post: in both cases I set the textField to allow rich text, used the font panel to set the font to Baskerville 13pt green text, typed, deleted the attributed string in the text field, and typed again. On the left, I retained focus; on the right, I switched focus to the first field before typing.