What to decide when registering fonts at runtime on iOS
A font that always ships inside the app never needs runtime registration. Add the file in Xcode and declare it under UIAppFonts in Info.plist, and the system loads it at launch. The guide to adding a custom font to your app explains this method.
Runtime registration is worth considering in other cases: a font downloaded from a server, a font chosen by theme, language, or content, a font inside a Swift Package, a framework, or a separate bundle, a font list that changes after the build, and a font swapped in for tests and previews. The two methods are used together, depending on what is required.
What follows covers how the API behaves and what has to be decided in operation, with example code. How the API behaves comes from the Apple documentation. Managing registration in one place, the ownership of the file and the order of deletion, and the advice to organize everything around the file URL are not requirements of the documentation. They are my own design choices. I compiled and ran the example code on macOS on October 4, 2026, and the result is in the section “What running it on macOS showed.” Results from running it in an iOS app and applying the font on screen are not in this post.
Registration lasts only within the process
Passing a file URL to CTFontManagerRegisterFontsForURL puts the fonts in that file into descriptor matching. After that they can be used with UIFont(name:size:) or Font.custom(_:size:). For a font used only inside the app, the scope is CTFontManagerScope.process. This registration disappears when the process ends, so it has to be done again on the next launch.
This is the simplest registration code.
import CoreText
func registerFont(at url: URL) throws {
var error: Unmanaged<CFError>?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() {
throw error
}
}
}
Three things are missing from this code. It does not check that the file exists, it does not find out the name of the font, and it has no handling for registering the same file twice.
A file name is not a font name
Registering a file named Pretendard-Regular.otf does not guarantee that the font can be called by the name Pretendard-Regular. What UIFont(name:size:) takes is a fully specified name.
The name is read from the file itself. CTFontManagerCreateFontDescriptorsFromURL returns the descriptors, and kCTFontNameAttribute gives the PostScript name. One file can hold several faces, as in a .ttc file, so the result is an array.
This function collects the PostScript names from the descriptors in a file.
import CoreText
func postScriptNames(in url: URL) -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
return []
}
return descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
}
During development, logging the names that were read makes it easy to confirm which name to call.
let names = postScriptNames(in: fontURL)
print("Font PostScript names:", names)
The output of an actual run is in the section “What running it on macOS showed.” The names that were read are gathered in one place, such as a design system or a registry. Writing the string directly in each view makes it hard to replace a font or trace an error.
An example that manages registration state in one place
Calling registration every time a cell is created, every time a SwiftUI body is evaluated, or every time a screen appears can cause duplicate registration errors and unnecessary cost. So one object is put in charge of registration. RuntimeFontRegistry.shared handles the file check, the name collection, and the record of registrations per URL.
First comes the part that holds the state. It stores the URL as the key and the array of names read from that file as the value. The three blocks that follow are the rest of this class. To combine them, remove the // … line and the closing brace below it from this block, then append the three blocks in order. The last block closes the class and declares the error type outside it.
import Foundation
import CoreText
final class RuntimeFontRegistry {
static let shared = RuntimeFontRegistry()
private var registeredURLs: [URL: [String]] = [:]
private let lock = NSLock()
private init() {}
// …
}
The register method normalizes the key with standardizedFileURL, checks that the file exists, and checks that the descriptors and names are valid. It then looks up the cache under an NSLock. If the URL is already registered, it returns the stored names. If not, it registers with .process and puts the array of names into the cache.
@discardableResult
func registerFont(at url: URL) throws -> [String] {
let url = url.standardizedFileURL
guard FileManager.default.fileExists(atPath: url.path) else {
throw FontRegistrationError.fileNotFound(url)
}
let names = try Self.readPostScriptNames(from: url)
lock.lock()
if let cached = registeredURLs[url] {
lock.unlock()
return cached
}
lock.unlock()
var error: Unmanaged<CFError>?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs[url] = names
lock.unlock()
return names
}
The unregister method calls CoreText with the same scope used for registration and removes the entry from the cache.
func unregisterFont(at url: URL) throws {
let url = url.standardizedFileURL
var error: Unmanaged<CFError>?
let success = CTFontManagerUnregisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs.removeValue(forKey: url)
lock.unlock()
}
This is the part that reads the names, and the error type. It tells apart a missing file, a file that is not a font, and a file with no PostScript name.
private static func readPostScriptNames(from url: URL) throws -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
!descriptors.isEmpty else {
throw FontRegistrationError.invalidFontFile(url)
}
let names = descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
guard !names.isEmpty else {
throw FontRegistrationError.missingPostScriptName(url)
}
return names
}
}
enum FontRegistrationError: Error {
case fileNotFound(URL)
case invalidFontFile(URL)
case missingPostScriptName(URL)
}
Some things are not handled in this example. Interpreting the error codes that CoreText returns is extra work. I recommend treating a repeated request for the same URL as success, and treating a different file with the same PostScript name as a conflict. A wrong or damaged file has to be handled separately from a duplicate or a name conflict.
Reading the code also shows some limits. NSLock is held only while the cache is looked up and while it is stored, and the CoreText call is outside the lock. So two simultaneous requests for the same URL can both enter registration. Also, when success is false but error is nil, the code does not throw and moves on to the next line. I read these two points from the code. I did not reproduce them by running it.
A downloaded font needs a decision about the file’s lifetime
If a file in a temporary location is registered, there is a risk that the system keeps a reference after the file is deleted. So the file is moved to a location the app manages, and registered after that.
This function creates a RuntimeFonts directory under Application Support.
func fontStorageDirectory() throws -> URL {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = baseURL.appendingPathComponent("RuntimeFonts", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
return directory
}
This function copies the downloaded file into that directory and registers it with the registry. The file name is the last path component of temporaryURL as it is, and if a file with the same name exists, it is deleted before the copy.
func installDownloadedFont(from temporaryURL: URL) throws -> [String] {
let directory = try fontStorageDirectory()
let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)
return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
This install example does not unregister before deleting the existing file. That does not match the advice below to unregister before deleting. If the file at an already registered URL is replaced, the registry can also return the names it cached earlier. This is another limit read from the code. The descriptor check happens inside the registry after the copy, and there is no code that checks the integrity of the download, the license, or a name conflict.
What has to be decided in operation lies outside this example: when to delete the file, how to register on the next launch, the fallback and what the user sees when a download fails, the server response and distribution, cache invalidation, the relation to the app version, and the license review.
What running it on macOS showed
I joined the Swift code of this post as it is, compiled it, and ran it. The run used Swift 6.4 on macOS 27.0.1. CoreText exists on macOS too, so registering and unregistering could be checked with no iOS device. There is no UIKit there, though, so the name was checked with CTFontCreateWithName in place of UIFont(name:size:). The font was PretendardStd-Bold.otf, which was already on this Mac and was copied to a temporary folder. It was not installed in the system.
The code compiled with no warnings. Below is the output of a run with calling code added that checks whether the font can be found by name before and after registration.
Font PostScript names: ["PretendardStd-Bold"]
before register: discoverable=false
registry.registerFont: ["PretendardStd-Bold"]
after register : discoverable=true
CTFontCreateWithName -> PretendardStd-Bold
registry.registerFont again (cached): ["PretendardStd-Bold"]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound(<url>)
Four things can be read from it.
- The font could not be found by name before registration, could be found after it, and could not be found again after unregistration.
- On the second registration, the registry returned the names it had cached. Registering the same URL again without going through the registry gave an error with code 105, the value of CoreText’s
kCTFontManagerErrorAlreadyRegistered. - Unregistering twice gave an error with code 201 (
kCTFontManagerErrorNotRegistered). - A missing file was caught as
fileNotFoundbefore CoreText was called.
With a font file that is already installed in the system, the same run found the font by name before registration and after unregistration as well. The same font remains at another path. Code that judges registration by name cannot tell that case apart. installDownloadedFont was not called.
Unregistering only removes the font from matching
Calling CTFontManagerUnregisterFontsForURL takes the font out of descriptor matching. It should not be read as cleaning up UIFont, CTFont, NSAttributedString, and SwiftUI views that already exist.
The lifetime is decided by how the font is used. A font needed for the whole run stays registered. For a temporarily downloaded resource, an owner is decided and that owner unregisters it. Unregistering is attempted before the file is deleted, and if it fails, the deletion is postponed. The scope for registering and unregistering has to be the same, so a font registered with .process is unregistered with .process.
CTFontManagerRegisterGraphicsFont is the API that registers a CGFont object. For a font in a file, the Apple documentation points to URL registration. In the documentation as rechecked on October 4, 2026, this API is marked deprecated as of iOS 18.0 and macOS 15.0, with a note to use CTFontManagerCreateFontDescriptorsFromData or CTFontManagerRegisterFontsForURL. That designing storage, registration, unregistration, and the cache as one unit around the file URL is easier to handle is my own judgment.
The lifetime of .process, the choice between URL registration and CGFont registration, and the effect of unregistering on objects that already exist need to be checked against the SDK and OS version. Branching per error code, conflict handling, safe file replacement and cache invalidation, registration after a restart, and the fallback policy are only described in this post and not implemented in the examples. What the examples do contain is the per-URL cache and a simple file replacement.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.