diff --git a/.gitignore b/.gitignore index 9d7c7e7..de694de 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ project.xcworkspace .settings local.properties android.iml +issue* # Cocoapods # diff --git a/example/android/app/src/main/assets/fonts/ChivoMono.ttf b/example/android/app/src/main/assets/fonts/ChivoMono.ttf new file mode 100644 index 0000000..ae5f374 Binary files /dev/null and b/example/android/app/src/main/assets/fonts/ChivoMono.ttf differ diff --git a/example/android/app/src/main/assets/fonts/Tourney.ttf b/example/android/app/src/main/assets/fonts/Tourney.ttf new file mode 100644 index 0000000..7832e6b Binary files /dev/null and b/example/android/app/src/main/assets/fonts/Tourney.ttf differ diff --git a/example/android/link-assets-manifest.json b/example/android/link-assets-manifest.json new file mode 100644 index 0000000..d6b92df --- /dev/null +++ b/example/android/link-assets-manifest.json @@ -0,0 +1,13 @@ +{ + "migIndex": 1, + "data": [ + { + "path": "assets/fonts/ChivoMono.ttf", + "sha1": "fe260b56219f603a8aaf7fba5cfd2c898bf2bca0" + }, + { + "path": "assets/fonts/Tourney.ttf", + "sha1": "1c7f16792740c9c6398692ea2149c5c3a6aaf6e5" + } + ] +} diff --git a/example/assets/fonts/ChivoMono.ttf b/example/assets/fonts/ChivoMono.ttf new file mode 100644 index 0000000..ae5f374 Binary files /dev/null and b/example/assets/fonts/ChivoMono.ttf differ diff --git a/example/assets/fonts/Tourney.ttf b/example/assets/fonts/Tourney.ttf new file mode 100644 index 0000000..7832e6b Binary files /dev/null and b/example/assets/fonts/Tourney.ttf differ diff --git a/example/ios/NitroTextExample.xcodeproj/project.pbxproj b/example/ios/NitroTextExample.xcodeproj/project.pbxproj index 2789fd6..b5605aa 100644 --- a/example/ios/NitroTextExample.xcodeproj/project.pbxproj +++ b/example/ios/NitroTextExample.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 94EE308912A9FD340098AA08 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 98F6E17588234B2CA2A4AE98 /* Tourney.ttf in Resources */ = {isa = PBXBuildFile; fileRef = FF9EF60F857E4AB0B9823535 /* Tourney.ttf */; }; + B5298A4D660D4F30A37B2814 /* ChivoMono.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3F04B18644AC4671BB0B3299 /* ChivoMono.ttf */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -20,11 +22,13 @@ 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = NitroTextExample/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = NitroTextExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 3B4392A12AC88292D35C810B /* Pods-NitroTextExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NitroTextExample.debug.xcconfig"; path = "Target Support Files/Pods-NitroTextExample/Pods-NitroTextExample.debug.xcconfig"; sourceTree = ""; }; + 3F04B18644AC4671BB0B3299 /* ChivoMono.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = ChivoMono.ttf; path = ../assets/fonts/ChivoMono.ttf; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-NitroTextExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NitroTextExample.release.xcconfig"; path = "Target Support Files/Pods-NitroTextExample/Pods-NitroTextExample.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-NitroTextExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NitroTextExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = NitroTextExample/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = NitroTextExample/LaunchScreen.storyboard; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FF9EF60F857E4AB0B9823535 /* Tourney.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = Tourney.ttf; path = ../assets/fonts/Tourney.ttf; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -51,6 +55,16 @@ name = NitroTextExample; sourceTree = ""; }; + 2D10C1EE03554F54B2107B66 /* Resources */ = { + isa = PBXGroup; + children = ( + FF9EF60F857E4AB0B9823535 /* Tourney.ttf */, + 3F04B18644AC4671BB0B3299 /* ChivoMono.ttf */, + ); + name = Resources; + path = ""; + sourceTree = ""; + }; 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { isa = PBXGroup; children = ( @@ -75,6 +89,7 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, + 2D10C1EE03554F54B2107B66 /* Resources */, ); indentWidth = 2; sourceTree = ""; @@ -161,6 +176,8 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 94EE308912A9FD340098AA08 /* PrivacyInfo.xcprivacy in Resources */, + 98F6E17588234B2CA2A4AE98 /* Tourney.ttf in Resources */, + B5298A4D660D4F30A37B2814 /* ChivoMono.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/ios/NitroTextExample/Info.plist b/example/ios/NitroTextExample/Info.plist index 0dd2e47..9d349e3 100644 --- a/example/ios/NitroTextExample/Info.plist +++ b/example/ios/NitroTextExample/Info.plist @@ -32,7 +32,7 @@ NSLocationWhenInUseUsageDescription - + RCTNewArchEnabled UILaunchStoryboardName @@ -49,5 +49,10 @@ UIViewControllerBasedStatusBarAppearance + UIAppFonts + + Tourney.ttf + ChivoMono.ttf + diff --git a/example/ios/link-assets-manifest.json b/example/ios/link-assets-manifest.json new file mode 100644 index 0000000..d6b92df --- /dev/null +++ b/example/ios/link-assets-manifest.json @@ -0,0 +1,13 @@ +{ + "migIndex": 1, + "data": [ + { + "path": "assets/fonts/ChivoMono.ttf", + "sha1": "fe260b56219f603a8aaf7fba5cfd2c898bf2bca0" + }, + { + "path": "assets/fonts/Tourney.ttf", + "sha1": "1c7f16792740c9c6398692ea2149c5c3a6aaf6e5" + } + ] +} diff --git a/example/react-native.config.js b/example/react-native.config.js index 4f4a36e..ea158d7 100644 --- a/example/react-native.config.js +++ b/example/react-native.config.js @@ -15,4 +15,5 @@ module.exports = { root: path.join(__dirname, '..'), }, }, + assets: ['./assets/fonts'], } diff --git a/example/src/screens/PerformanceScreen.tsx b/example/src/screens/PerformanceScreen.tsx index 160ca88..b7c5de3 100644 --- a/example/src/screens/PerformanceScreen.tsx +++ b/example/src/screens/PerformanceScreen.tsx @@ -630,7 +630,7 @@ import React, { diff --git a/example/src/screens/PlainTextScreen.tsx b/example/src/screens/PlainTextScreen.tsx index 55e0943..cb15eec 100644 --- a/example/src/screens/PlainTextScreen.tsx +++ b/example/src/screens/PlainTextScreen.tsx @@ -17,16 +17,7 @@ export function PlainTextScreen() { {/* Header Section */} - console.log('onPressIn')} - onPressOut={() => console.log('onPressOut')} - onLongPress={() => console.log('onLongPress')} - onPress={() => console.log('onPress')} - > - 🚀 NitroText Plain Text {NitroModules.buildType} - + 🚀 NitroText Plain Text {NitroModules.buildType} High-performance selectable text with native rendering @@ -93,6 +84,35 @@ export function PlainTextScreen() { + {/* Custom Fonts */} + + Custom Fonts + + NitroText supports custom fonts from your assets folder. The Tourney + font is loaded from @assets/fonts/ChivoMono.ttf + + + This text uses the Tourney custom font with NitroText. You can apply + custom fonts using the fontFamily prop in styles or directly on the + component. + + + Mix custom fonts with{' '} + bold styling and{' '} + italic styling{' '} + for rich typography. + + + You can also nest custom fonts:{' '} + + Large Tourney Bold + {' '} + mixed with regular system font text. + + + {/* Mixed Content */} Mixed Content @@ -192,4 +212,3 @@ export function PlainTextScreen() { ); } - diff --git a/example/src/screens/styles.ts b/example/src/screens/styles.ts index be10409..02ccb6f 100644 --- a/example/src/screens/styles.ts +++ b/example/src/screens/styles.ts @@ -29,8 +29,6 @@ export const styles = StyleSheet.create({ fontWeight: 'bold', color: '#1a1a1a', textAlign: 'center', - fontFamily: 'ui-monospace', - // marginBottom: 8, backgroundColor: 'red', }, subtitle: { @@ -50,6 +48,7 @@ export const styles = StyleSheet.create({ fontSize: 14, color: '#6c757d', marginBottom: 8, + fontFamily: 'Chivo Mono', }, // Basic text styles @@ -73,6 +72,45 @@ export const styles = StyleSheet.create({ padding: 16, borderRadius: 8, }, + // Custom font styles + customFontExample: { + fontSize: 18, + fontFamily: 'Tourney', + color: '#2c3e50', + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + borderLeftWidth: 4, + borderLeftColor: '#9b59b6', + marginTop: 8, + }, + customFontMixed: { + fontSize: 16, + fontFamily: 'Tourney', + color: '#495057', + backgroundColor: '#f8f9fa', + padding: 16, + borderRadius: 8, + marginTop: 8, + lineHeight: 24, + }, + customFontBold: { + fontWeight: 'bold', + color: '#8e44ad', + }, + customFontItalic: { + fontStyle: 'italic', + color: '#9b59b6', + }, + customFontNested: { + fontSize: 16, + color: '#495057', + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 8, + marginTop: 8, + lineHeight: 24, + }, bold: { fontWeight: 'bold', color: '#2c3e50', diff --git a/ios/NitroTextImpl+Font.swift b/ios/NitroTextImpl+Font.swift index 2346a0d..cf05b38 100644 --- a/ios/NitroTextImpl+Font.swift +++ b/ios/NitroTextImpl+Font.swift @@ -15,6 +15,8 @@ struct FontKey: Hashable { } extension NitroTextImpl { + private static let defaultFontFamily = UIFont.systemFont(ofSize: 14).familyName + func makeFont(for fragment: Fragment, defaultPointSize: CGFloat?) -> ( value: UIFont, isItalic: Bool ) { @@ -28,85 +30,110 @@ extension NitroTextImpl { ? (resolvedSize * getScaleFactor(requestedSize: resolvedSize)) : resolvedSize let weightToken = fragment.fontWeight ?? FontWeight.normal let uiWeight = Self.uiFontWeight(for: weightToken) - let isItalic = fragment.fontStyle == FontStyle.italic + + let hasExplicitWeight = fragment.fontWeight != nil + let hasExplicitStyle = fragment.fontStyle != nil + var isItalic = fragment.fontStyle == FontStyle.italic let requestedFamily = fragment.fontFamily ?? currentFontFamily + let resolvedFamily: String = { + if let family = requestedFamily, !family.isEmpty { return family } + return Self.defaultFontFamily + }() - let key = FontKey(size: finalPointSize, weightRaw: uiWeight.rawValue, italic: isItalic, family: requestedFamily) + let key = FontKey(size: finalPointSize, weightRaw: uiWeight.rawValue, italic: isItalic, family: resolvedFamily) if let cached = fontCache[key] { return (cached, isItalic) } - var base: UIFont - if let family = requestedFamily, !family.isEmpty { - let f = family.lowercased() - switch f { - case "system-ui", "ui-sans-serif": - // Default SF system font - base = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) + var targetWeight = uiWeight + var familyName = resolvedFamily + let normalizedFamilyName = familyName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let isSystemCondensed = normalizedFamilyName == "systemcondensed" + let isSystemFamily = + familyName == Self.defaultFontFamily + || normalizedFamilyName == "system-ui" + || normalizedFamilyName == "system" + || isSystemCondensed + var isCondensed = isSystemCondensed + var didFindFont = false + var base: UIFont? = nil + + let systemDesign: UIFontDescriptor.SystemDesign? = { + switch normalizedFamilyName { + case "system-ui", "system": + return nil case "ui-serif": - let sys = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) - if #available(iOS 13.0, *) { - if let desc = sys.fontDescriptor.withDesign(UIFontDescriptor.SystemDesign.serif) { - base = UIFont(descriptor: desc, size: finalPointSize) - } else { - base = sys - } - } else { - base = sys - } + return .serif case "ui-rounded": - let sys = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) - if #available(iOS 13.0, *) { - if let desc = sys.fontDescriptor.withDesign(UIFontDescriptor.SystemDesign.rounded) { - base = UIFont(descriptor: desc, size: finalPointSize) - } else { - base = sys - } - } else { - base = sys - } + return .rounded case "ui-monospace": - if #available(iOS 13.0, *) { - base = UIFont.monospacedSystemFont(ofSize: finalPointSize, weight: uiWeight) - } else { - base = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) - } + return .monospaced + case "systemcondensed": + return .default default: - // Try to build a descriptor with family + traits - let baseDesc = UIFontDescriptor(fontAttributes: [UIFontDescriptor.AttributeName.family: family]) - var traits = [UIFontDescriptor.TraitKey: Any]() - traits[.weight] = uiWeight - var symbolic: UIFontDescriptor.SymbolicTraits = [] - if isItalic { symbolic.insert(.traitItalic) } - let withTraits = baseDesc.addingAttributes([ - UIFontDescriptor.AttributeName.traits: traits, - UIFontDescriptor.AttributeName.face: "", - ]) - if let finalDesc = withTraits.withSymbolicTraits(symbolic) { - base = UIFont(descriptor: finalDesc, size: finalPointSize) - } else if let named = UIFont(name: family, size: finalPointSize) { - base = named - } else { - base = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) - } + return nil + } + }() + + if isSystemFamily || systemDesign != nil + { + base = UIFont.systemFont(ofSize: finalPointSize, weight: targetWeight) + didFindFont = true + + var descriptor = base?.fontDescriptor + if let design = systemDesign { + descriptor = descriptor?.withDesign(design) + } + if isItalic || isCondensed { + var traits = descriptor?.symbolicTraits ?? [] + if isItalic { traits.insert(.traitItalic) } + if isCondensed { traits.insert(.traitCondensed) } + descriptor = descriptor?.withSymbolicTraits(traits) + } + if let descriptor = descriptor { + base = UIFont(descriptor: descriptor, size: finalPointSize) + } + } + + var fontsInFamily = UIFont.fontNames(forFamilyName: familyName) + + // Gracefully handle being given a font name rather than a family name + if !didFindFont && fontsInFamily.isEmpty { + if let named = UIFont(name: familyName, size: finalPointSize) { + base = named + familyName = named.familyName + fontsInFamily = UIFont.fontNames(forFamilyName: familyName) + if !hasExplicitWeight { targetWeight = Self.fontWeight(from: named) } + if !hasExplicitStyle { isItalic = Self.isItalicFont(named) } + isCondensed = Self.isCondensedFont(named) + } else { + base = UIFont.systemFont(ofSize: finalPointSize, weight: targetWeight) } - } else { - base = UIFont.systemFont(ofSize: finalPointSize, weight: uiWeight) } - if isItalic, !base.fontDescriptor.symbolicTraits.contains(.traitItalic) { - var traits = base.fontDescriptor.symbolicTraits - traits.insert(.traitItalic) - if let italicDesc = base.fontDescriptor.withSymbolicTraits(traits) { - let traitsDict: [UIFontDescriptor.TraitKey: Any] = [.weight: uiWeight] - let finalDesc = italicDesc.addingAttributes([ - UIFontDescriptor.AttributeName.traits: traitsDict - ]) - base = UIFont(descriptor: finalDesc, size: finalPointSize) + // Find closest weight match within the family respecting italic/condensed traits + if !didFindFont { + var closestWeight = CGFloat.infinity + for name in fontsInFamily { + guard let match = UIFont(name: name, size: finalPointSize) else { continue } + if isItalic == Self.isItalicFont(match) && isCondensed == Self.isCondensedFont(match) { + let testWeight = Self.fontWeight(from: match) + if abs(testWeight.rawValue - targetWeight.rawValue) < abs(closestWeight - targetWeight.rawValue) { + base = match + closestWeight = testWeight.rawValue + } + } } } - fontCache[key] = base - return (base, isItalic) + + // If no exact match, pick the first font from the family + if base == nil, let first = fontsInFamily.first { + base = UIFont(name: first, size: finalPointSize) + } + + let finalFont = base ?? UIFont.systemFont(ofSize: finalPointSize, weight: targetWeight) + fontCache[key] = finalFont + return (finalFont, isItalic) } func getScaleFactor(requestedSize: CGFloat) -> CGFloat { @@ -187,4 +214,41 @@ extension NitroTextImpl { return .regular } } + + private static func fontWeight(from font: UIFont) -> UIFont.Weight { + let suffixes: [(String, UIFont.Weight)] = [ + ("normal", .regular), + ("ultralight", .ultraLight), + ("thin", .thin), + ("light", .light), + ("regular", .regular), + ("medium", .medium), + ("semibold", .semibold), + ("demibold", .semibold), + ("extrabold", .heavy), + ("ultrabold", .heavy), + ("bold", .bold), + ("heavy", .heavy), + ("black", .black) + ] + + let name = font.fontName.lowercased() + if let match = suffixes.first(where: { name.hasSuffix($0.0) }) { + return match.1 + } + + if let traits = font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any], + let raw = traits[.weight] as? NSNumber { + return UIFont.Weight(rawValue: CGFloat(truncating: raw)) + } + return .regular + } + + private static func isItalicFont(_ font: UIFont) -> Bool { + font.fontDescriptor.symbolicTraits.contains(.traitItalic) + } + + private static func isCondensedFont(_ font: UIFont) -> Bool { + font.fontDescriptor.symbolicTraits.contains(.traitCondensed) + } } diff --git a/src/nitro-text.tsx b/src/nitro-text.tsx index 65dac79..d48dab4 100644 --- a/src/nitro-text.tsx +++ b/src/nitro-text.tsx @@ -93,6 +93,34 @@ export const NitroText = (props: NitroTextPropsWithEvents) => { [onTextLayout] ) + const textProps = useMemo(() => { + return { + ...rest, + selectable: selectable || false, + maxFontSizeMultiplier: maxFontSizeMultiplier || undefined, + fragments: parsedFragments || undefined, + selectionColor: (selectionColor as string) || undefined, + onPress: callback(onPress) || undefined, + onPressIn: callback(onPressIn) || undefined, + onPressOut: callback(onPressOut) || undefined, + style, + ...styleProps, + onTextLayout: callback(onTextLayout) || undefined, + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + rest, + styleProps, + selectable, + maxFontSizeMultiplier, + parsedFragments, + selectionColor, + onPress, + onPressIn, + onPressOut, + onTextLayout, + ]) + if (isInsideRNText || Platform.OS === 'android') { return ( { } if (renderer && isStringChildren) { - return ( - - ) + return } if (isSimpleText) { - return ( - - ) + return } - return ( - - ) + return } NitroText.displayName = 'NitroText'