Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Xcode 12b1 & Swift Packages: Custom Fonts

Tags:

ios

swift

swiftui

I have been able to ship some image and asset catalogs in a Swift package with success using Xcode 12b1 and Swift 5.3. I am not having so much luck with using a custom .ttf file in a Swift Package.

I am loading a .ttf file in the manifest like so:

.target(
  name: "BestPackage",
  dependencies: [],
  resources: [
    .copy("Resources/Fonts/CustomFont.ttf"),
    .process("Resources/Colors.xcassets")
  ]
),

And I noticed that there's no initializer on the Font type in SwiftUI to include an asset from a module. For example, this works:

static var PrimaryButtonBackgroundColor: SwiftUI.Color {
  Color("Components/Button/Background", bundle: .module)
}

However, there's no way to specify where a font is coming from. I was hoping that loading it into the module would emit it into the target for use, but no such luck:

static var PrimaryButtonFont: Font {
  Font.custom("CustomFont", size: 34)
}

This does not load the font as expected. I'm investigating using a CoreText api to try and trick it into loading, but I feel like there should be an easier way. Any advice?

Update

Still no success but I was able to prove that the font is indeed inside the module

I wrote a method to get available font URLs from the module like so:

  static func fontNames() -> [URL] {
    let bundle = Bundle.module
    let filenames = ["CustomFont"]
    return filenames.map { bundle.url(forResource: $0, withExtension: "ttf")! }
  }

Calling this method at runtime and printing the result yields this:

font names: [file:///Users/davidokun/Library/Developer/CoreSimulator/Devices/AFE4ADA0-83A7-46AE-9116-7870B883DBD3/data/Containers/Bundle/Application/800AE766-FB60-4AFD-B57A-0E9F3EACCDB2/BestPackageTesting.app/BestPackage_BestPackage.bundle/CustomFont.ttf]

I then tried to register the font for use in the runtime with the following method:

extension UIFont {
  static func register(from url: URL) {
    guard let fontDataProvider = CGDataProvider(url: url as CFURL) else {
      print("could not get reference to font data provider")
      return
    }
    guard let font = CGFont(fontDataProvider) else {
      print("could not get font from coregraphics")
      return
    }
    var error: Unmanaged<CFError>?
    guard CTFontManagerRegisterGraphicsFont(font, &error) else {
      print("error registering font: \(error.debugDescription)")
      return
    }
  }
}

When I call it like so:

fontNames().forEach { UIFont.register(from: $0) }

I get this error:

error registering font: Optional(Swift.Unmanaged<__C.CFErrorRef>(_value: Error Domain=com.apple.CoreText.CTFontManagerErrorDomain Code=105 "Could not register the CGFont '<CGFont (0x600000627a00): CustomFont>'" UserInfo={NSDescription=Could not register the CGFont '<CGFont (0x600000627a00): CustomFont>', CTFailedCGFont=<CGFont (0x600000627a00): CustomFont>}))

Any more ideas are welcome.

like image 409
dokun1 Avatar asked Jul 01 '20 16:07

dokun1


2 Answers

I managed to import custom fonts using SPM, using this SO answer to help https://stackoverflow.com/a/36871032/5508175

Here is what I did. Create your package and add your fonts. Here is my Package.swift

// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "MyFonts",
    products: [
        .library(
            name: "MyFonts",
            targets: ["MyFonts"]),
    ],
    dependencies: [
    ],
    targets: [

        .target(
            name: "MyFonts",
            dependencies: [],
            resources: [.process("Fonts")]),
        .testTarget(
            name: "MyFontsTests",
            dependencies: ["MyFonts"]),
    ]
)

Here is my folder structure. I have all of my fonts contained in a folder called Fonts.

Image of my folder structure

Inside MyFonts.swift I do the following:

import Foundation // This is important remember to import Foundation

public let fontBundle = Bundle.module

This allows me access to the Bundle outside of the package.

Next I added the package to my project. It is a SwiftUI project with an AppDelegate.

  • import MyFonts
  • In didFinishLaunchingWithOptions check to see if the font files are available (optional)
  • Add the fonts using the extension to UIFont.
  • Print out the fonts to check that they are installed (optional)

So here is my AppDelegate:

import UIKit
import MyFonts

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // This prints out the files that are stored in the MyFont bundle
        // Just doing this to check that the fonts are actually in the bundle
        if let files = try? FileManager.default.contentsOfDirectory(atPath: fontBundle.bundlePath ){
            for file in files {
                print(file)
            }
        }

        // This registers the fonts
        _ = UIFont.registerFont(bundle: fontBundle, fontName: "FiraCode-Medium", fontExtension: "ttf")
        _ = UIFont.registerFont(bundle: fontBundle, fontName: "FiraCode-Bold", fontExtension: "ttf")
        _ = UIFont.registerFont(bundle: fontBundle, fontName: "FiraCode-Light", fontExtension: "ttf")
        _ = UIFont.registerFont(bundle: fontBundle, fontName: "FiraCode-Regular", fontExtension: "ttf")
        _ = UIFont.registerFont(bundle: fontBundle, fontName: "FiraCode-Retina", fontExtension: "ttf")

        // This prints out all the fonts available you should notice that your custom font appears in this list
        for family in UIFont.familyNames.sorted() {
            let names = UIFont.fontNames(forFamilyName: family)
            print("Family: \(family) Font names: \(names)")
        }

        return true
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {}
}

// This extension is taken from this SO answer https://stackoverflow.com/a/36871032/5508175
extension UIFont {
    static func registerFont(bundle: Bundle, fontName: String, fontExtension: String) -> Bool {

        guard let fontURL = bundle.url(forResource: fontName, withExtension: fontExtension) else {
            fatalError("Couldn't find font \(fontName)")
        }

        guard let fontDataProvider = CGDataProvider(url: fontURL as CFURL) else {
            fatalError("Couldn't load data from the font \(fontName)")
        }

        guard let font = CGFont(fontDataProvider) else {
            fatalError("Couldn't create font from data")
        }

        var error: Unmanaged<CFError>?
        let success = CTFontManagerRegisterGraphicsFont(font, &error)
        guard success else {
            print("Error registering font: maybe it was already registered.")
            return false
        }

        return true
    }
}

Then in you ContentView you can do something like this:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("Hello San Francisco")
            Text("Hello FiraCode Medium").font(Font.custom("FiraCode-Medium", size: 16))
            Text("Hello FiraCode Bold").font(Font.custom("FiraCode-Bold", size: 16))
            Text("Hello FiraCode Light").font(Font.custom("FiraCode-Light", size: 16))
            Text("Hello FiraCode Regular").font(Font.custom("FiraCode-Regular", size: 16))
            Text("Hello FiraCode Retina").font(Font.custom("FiraCode-Retina", size: 16))
        }
    }
}

Which gives the following result:

Image of custom fonts on iPhone SE


Caveats

I haven't tried this in a fully SwiftUI app, but you can follow the tutorial shown here on how to add an AppDelegate if you don't have one.

Obviously the printing of the files in the fontBundle and the fonts that are installed are optional. They are just useful for debugging and for making sure that you have the correct font name The filename can differ quite considerably from the font name that you have to use to display the font. See my SO post about adding custom fonts:


Update

I wondered if it was possible to create a function that was contained in the package and calling that would load the fonts. Apparently it is.

I updated to MyFonts.swift to the following:

import Foundation
import UIKit

public func registerFonts() {
    _ = UIFont.registerFont(bundle: .module, fontName: "FiraCode-Medium", fontExtension: "ttf")
    _ = UIFont.registerFont(bundle: .module, fontName: "FiraCode-Bold", fontExtension: "ttf")
    _ = UIFont.registerFont(bundle: .module, fontName: "FiraCode-Light", fontExtension: "ttf")
    _ = UIFont.registerFont(bundle: .module, fontName: "FiraCode-Regular", fontExtension: "ttf")
    _ = UIFont.registerFont(bundle: .module, fontName: "FiraCode-Retina", fontExtension: "ttf")
}

extension UIFont {
    static func registerFont(bundle: Bundle, fontName: String, fontExtension: String) -> Bool {

        guard let fontURL = bundle.url(forResource: fontName, withExtension: fontExtension) else {
            fatalError("Couldn't find font \(fontName)")
        }

        guard let fontDataProvider = CGDataProvider(url: fontURL as CFURL) else {
            fatalError("Couldn't load data from the font \(fontName)")
        }

        guard let font = CGFont(fontDataProvider) else {
            fatalError("Couldn't create font from data")
        }

        var error: Unmanaged<CFError>?
        let success = CTFontManagerRegisterGraphicsFont(font, &error)
        guard success else {
            print("Error registering font: maybe it was already registered.")
            return false
        }

        return true
    }
}

This meant that I could remove the extension from the AppDelegate and I don't have to register each font in the AppDelegate like I did before I just call registerFonts()

So my didFinishLaunchingWithOptions now looks like this:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // This registers the fonts
    registerFonts()

    return true
}

Remember that you still have to import your package.

like image 169
Andrew Avatar answered Nov 11 '22 22:11

Andrew


Here is a simplified version of @Andrew's answer. I have tested this in a 100% SwiftUI app on iOS and macOS; it does not require UIKit. Fonts registered in this way can be accessed from other dependent packages.

func registerFont(_ name: String, fileExtension: String) {
    guard let fontURL = Bundle.module.url(forResource: name, withExtension: fileExtension) else {
        print("No font named \(name).\(fileExtension) was found in the module bundle")
        return
    }

    var error: Unmanaged<CFError>?
    CTFontManagerRegisterFontsForURL(fontURL as CFURL, .process, &error)
    print(error ?? "Successfully registered font: \(name)")
}

You should load your font and color assets into your package like this:

.target(
  name: "BestPackage",
  dependencies: [],
  resources: [
    .process("Resources")
  ]
),

From the docs:

If the given path represents a directory, Xcode applies the process rule recursively to each file in the directory.

If possible, use this rule instead of copy(_:).

like image 33
SlimeBaron Avatar answered Nov 11 '22 23:11

SlimeBaron