Custom Fonts

type nerds unite

I wrote a three and a half part series on adding custom fonts to your iOS app while maintaining support for users’ preferred content sizes.

The full code is below.

App Delegate

To use the extensions, add the following lines to your app delegate’s application(_:, willFinishLaunchingWithOptions:) method:

UIFont.loadAppFonts()
appearanceManager = AppearanceManager(rootViewController: window.rootViewController!)

View Controllers

Add a single line to the viewDidLoad() method in each of your view controllers:

override func viewDidLoad() {
    …
    recursivelyUpdateFontsForContentSize()
}

Cell Views

For any custom views, such as table view or collection view cells, be sure to update fonts on load and reuse:

override func awakeFromNib() {
    …        
    recursivelyUpdateFontsForContentSize()
}

override func prepareForReuse() {
    …
    recursivelyUpdateFontsForContentSize()
}

Extensions

Add these two files to your project.

UIFontExtensions.swift

This extension on UIFont provides for custom font loading and implements an associated objects wormhole to keep named style information with custom fonts. Replace the font names with those of your licensed custom fonts.

//
//  UIFontExtensions.swift
//  SmartGoals
//
//  Created by Curt Clifton on 2/12/17.
//  Copyright © 2017 curtclifton.net. All rights reserved.
//

import UIKit

private var baseStyleAssociatedObjectKey = 0

extension UIFont {
    static func loadAppFonts() {
        guard let fontDirectory = Bundle.main.resourceURL?.appendingPathComponent("Fonts", isDirectory: true) else {
            assertionFailure("failed to find font directory")
            return
        }

        guard let fontFileURLs = try? FileManager.default.contentsOfDirectory(at: fontDirectory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]) else {
            assertionFailure("failed to find font files")
            return
        }
        
        fontFileURLs.forEach { loadFont(from: $0) }
    }
    
    private static func loadFont(from url: URL) {
        var errorRef: Unmanaged?
        let success = CTFontManagerRegisterFontsForURL(url as CFURL, CTFontManagerScope.process, &errorRef)
        if !success, let fontError = errorRef?.takeRetainedValue() {
            if let errorDescription = CFErrorCopyDescription(fontError) as String? {
                assertionFailure("Failed to load font from url “\(url)”: \(errorDescription)")
            } else {
                assertionFailure("Failed to load font from url “\(url)”")
            }
        }
    }
    
    private var baseStyle: UIFontTextStyle? {
        get {
            if let systemStyle = fontDescriptor.object(forKey: UIFontDescriptorTextStyleAttribute) as? UIFontTextStyle {
                return systemStyle
            }
            
            if let associatedStyle = objc_getAssociatedObject(self, &baseStyleAssociatedObjectKey) as? UIFontTextStyle {
                return associatedStyle
            }
            
            return nil
        }
        set {
            if fontDescriptor.object(forKey: UIFontDescriptorTextStyleAttribute) is UIFontTextStyle {
                // ignore for system fonts
                return
            }

            objc_setAssociatedObject(self, &baseStyleAssociatedObjectKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
        }
    }
    
    var smallCapsVariant: UIFont {
        let smallCapsFontDescriptor = fontDescriptor.addingAttributes(
            [
                UIFontDescriptorFeatureSettingsAttribute: [
                    [UIFontFeatureTypeIdentifierKey: kLowerCaseType, UIFontFeatureSelectorIdentifierKey: kLowerCaseSmallCapsSelector],
                ]
            ]
        )
        
        let smallCapsFont = UIFont(descriptor: smallCapsFontDescriptor, size: 0)
        return smallCapsFont
    }
    
    /// Returns the app-specific font with the same UIFontTextStyle as this, but sized to the current content size category.
    func appFontWithSameStyle() -> UIFont {
        assert(baseStyle != nil)
        return UIFont.appFont(for: baseStyle ?? .body)
    }
    
    class func appFont(for style: UIFontTextStyle) -> UIFont {
        let preferredFont = UIFont.preferredFont(forTextStyle: style)
        let pointSize = preferredFont.pointSize
        
        let font: UIFont?
        
        switch style {
        case UIFontTextStyle.title1:
            font = UIFont(name: "MuseoSans-700", size: pointSize)
        case UIFontTextStyle.title2:
            font = UIFont(name: "MuseoSans-500", size: pointSize)
        case UIFontTextStyle.title3:
            font = UIFont(name: "MuseoSans-500", size: pointSize)
        case UIFontTextStyle.headline:
            font = UIFont(name: "MuseoSans-700", size: pointSize)
        case UIFontTextStyle.subheadline:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.body:
            font = UIFont(name: "MuseoSlab-500", size: pointSize)
        case UIFontTextStyle.callout:
            font = UIFont(name: "MuseoSlab-500", size: pointSize)
        case UIFontTextStyle.caption1:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.caption2:
            font = UIFont(name: "MuseoSans-300", size: pointSize)
        case UIFontTextStyle.footnote:
            font = UIFont(name: "MuseoSlab-300", size: pointSize)
        default:
            // Other font styles are undocumented. We could use the system preferred font for the style or force our body font. The latter precludes us from using the System Font choice in Interface Builder, so let's go with the former.
            font = preferredFont
        }
        
        assert(font != nil, "Failed to load app font for style “\(style)”")
        
        let actualFont = font ?? preferredFont // fall-back to preferred font if necessary
        actualFont.baseStyle = style // carry the base style forward
        return actualFont
    }
    
    class func appFont(ofSize size: CGFloat) -> UIFont {
        let font = UIFont(name: "MuseoSlab-500", size: size)
        return font ?? systemFont(ofSize: size)
    }
    
    class var navigationItemFont: UIFont {
        return appFont(ofSize: 17)
    }
    
    class var navigationBarTitleFont: UIFont {
        return UIFont.appFont(for: .headline)
    }
    
    class var buttonFont: UIFont {
        return appFont(ofSize: 17)
    }
}

AppearanceManager.swift

This class, and associated micro-extensions on UIKit classes, automate update of fonts without scattering (too much) code in your individual view controllers.

//
//  AppearanceManager.swift
//  SmartGoals
//
//  Created by Curt Clifton on 3/19/17.
//  Copyright © 2017 curtclifton.net. All rights reserved.
//

import UIKit

/// Controls app-wide appearance.
///
/// Responsible for updating appearance proxies and passing font update messages down the view controller and view hierarchies when the system font size changes.
class AppearanceManager: NSObject {
    private weak var rootViewController: UIViewController?
    
    init(rootViewController: UIViewController) {
        self.rootViewController = rootViewController
        
        super.init()
        
        UIBarButtonItem.appearance().setTitleTextAttributes([NSFontAttributeName: UIFont.navigationItemFont], for: [.normal])
        UINavigationBar.appearance().titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]
        let pageControlAppearance = UIPageControl.appearance()
        pageControlAppearance.currentPageIndicatorTintColor = .currentPageIndicator
        pageControlAppearance.pageIndicatorTintColor = .pageIndicator
        
        NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: .UIContentSizeCategoryDidChange, object: nil)
    }
    
    dynamic private func contentSizeCategoryDidChange(_ notification: Notification) {
        rootViewController?.recursivelyUpdateFontsForContentSize()
    }
}

protocol AppearanceManagerUpdatable {
    func updateFontsForContentSizeChange()
    func recursivelyUpdateFontsForContentSize()
}

extension UIViewController: AppearanceManagerUpdatable {
    /// Default implementation calls `updateFontsForContentSizeChange()` on every view in the view hierarchy rooted at `self.viewIfLoaded`.
    func updateFontsForContentSizeChange() {
        viewIfLoaded?.recursivelyUpdateFontsForContentSize()
    }
    
    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all child view controllers, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for viewController in childViewControllers {
            viewController.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

extension UITableViewController {
    override func updateFontsForContentSizeChange() {
        tableView.reloadData()
    }
}

extension UICollectionViewController {
    override func updateFontsForContentSizeChange() {
        collectionView?.reloadData()
    }
}

extension UIView: AppearanceManagerUpdatable {
    /// Default implementation does nothing.
    ///
    /// Subclasses with font attributes should override to update those fonts.
    func updateFontsForContentSizeChange() {
        // no-op
    }
    
    /// Calls `recursivelyUpdateFontsForContentSize()` recursively on all subviews, then calls `updateFontsForContentSizeChange()` on self.
    func recursivelyUpdateFontsForContentSize() {
        for view in subviews {
            view.recursivelyUpdateFontsForContentSize()
        }
        updateFontsForContentSizeChange()
    }
}

extension UILabel {
    override func updateFontsForContentSizeChange() {
        font = font.appFontWithSameStyle()
    }
}

extension UITextField {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

extension UITextView {
    override func updateFontsForContentSizeChange() {
        guard let currentFont = font else {
            return
        }
        font = currentFont.appFontWithSameStyle()
    }
}

extension UITabBar {
    override func recursivelyUpdateFontsForContentSize() {
        // Leave tab bar untouched. It doesn't like having subviews twiddled.
    }
}

extension UINavigationBar {
    override func updateFontsForContentSizeChange() {
        titleTextAttributes = [NSFontAttributeName: UIFont.navigationBarTitleFont]
    }
    
    override func recursivelyUpdateFontsForContentSize() {
        // Leave navigation bar subviews untouched. It doesn't like having subviews twiddled. However, still update self.
        updateFontsForContentSizeChange()
    }
}

extension UIButton {
    override func updateFontsForContentSizeChange() {
        titleLabel?.font = titleLabel!.font.smallCapsVariant
    }
    
    override func recursivelyUpdateFontsForContentSize() {
        // Leave button subviews untouched. However, still update self.
        updateFontsForContentSizeChange()
    }
}