Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions data/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"shadowRadius": "shr",
"fontSize": "fs",
"fontWeight": "fw",
"fontFamily": "ff",
"color": "c",
"letterSpacing": "ls",
"fontVariant": "fvar",
Expand Down Expand Up @@ -151,6 +152,7 @@
"shadowRadius",
"fontSize",
"fontWeight",
"fontFamily",
"color",
"letterSpacing",
"fontVariant",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"supportedFamilies": ["systemSmall", "systemMedium", "systemLarge"],
"initialStatePath": "./widgets/weather-initial.tsx"
}
],
"fonts": [
"node_modules/@expo-google-fonts/merriweather/400Regular/Merriweather_400Regular.ttf",
"node_modules/@expo-google-fonts/merriweather/700Bold/Merriweather_700Bold.ttf"
]
}
],
Expand Down
1 change: 1 addition & 0 deletions example/components/live-activities/BasicLiveActivityUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function BasicLiveActivityUI() {
fontSize: 28,
fontWeight: '700',
letterSpacing: -0.5,
fontFamily: 'Merriweather-Regular',
}}
>
Hello, Voltra!
Expand Down
7 changes: 7 additions & 0 deletions example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"postinstall": "patch-package"
},
"dependencies": {
"@expo-google-fonts/merriweather": "^0.4.2",
"@react-native-community/datetimepicker": "^8.4.1",
"@react-native-harness/ui": "1.0.0-alpha.23",
"@react-navigation/elements": "^2.5.2",
Expand Down
1 change: 1 addition & 0 deletions ios/shared/ShortNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public enum ShortNames {
"fg": "flexGrow",
"fgw": "flexGrowWidth",
"fnt": "font",
"ff": "fontFamily",
"fs": "fontSize",
"fvar": "fontVariant",
"fw": "fontWeight",
Expand Down
4 changes: 4 additions & 0 deletions ios/ui/Style/StyleConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ enum StyleConverter {
style.fontWeight = JSStyleParser.fontWeight(weightRaw)
}

if let fontFamily = js["fontFamily"] as? String {
style.fontFamily = fontFamily
}

if let alignRaw = js["textAlign"] {
style.alignment = JSStyleParser.textAlignment(alignRaw)
}
Expand Down
8 changes: 7 additions & 1 deletion ios/ui/Style/TextStyle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ struct TextStyle {
var color: Color = .primary
var fontSize: CGFloat = 17
var fontWeight: Font.Weight = .regular
var fontFamily: String?
var alignment: TextAlignment = .leading
var lineLimit: Int?
var lineSpacing: CGFloat = 0 // Extra space between lines
Expand All @@ -20,7 +21,12 @@ struct TextStyleModifier: ViewModifier {
content
// 1. Font Construction
// We build the system font dynamically based on config
.font(.system(size: style.fontSize, weight: style.fontWeight))
// If fontFamily is specified, use custom font, otherwise use system font
.font(
style.fontFamily != nil
? .custom(style.fontFamily!, size: style.fontSize)
: .system(size: style.fontSize, weight: style.fontWeight)
)
// 2. Color
.foregroundColor(style.color)
// 3. Layout / Spacing
Expand Down
16 changes: 16 additions & 0 deletions ios/ui/Views/VoltraText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ public struct VoltraText: VoltraView {
let textStyle = style.3

var font: Font {
// If custom fontFamily is specified, use it
if let fontFamily = textStyle.fontFamily {
var baseFont = Font.custom(fontFamily, size: textStyle.fontSize)

if textStyle.fontVariant.contains(.smallCaps) {
baseFont = baseFont.smallCaps()
}

if textStyle.fontVariant.contains(.tabularNums) {
baseFont = baseFont.monospacedDigit()
}

return baseFont
}

// Otherwise use system font with weight
var baseFont = Font.system(size: textStyle.fontSize, weight: textStyle.fontWeight)

if textStyle.fontVariant.contains(.smallCaps) {
Expand Down
157 changes: 157 additions & 0 deletions plugin/src/features/ios/fonts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Font configuration for Live Activity extension.
*
* This code is adapted from expo-font to support custom fonts in Live Activities.
* @see https://github.com/expo/expo/tree/main/packages/expo-font/plugin
*
* I would love to reuse the existing expo-font infrastructure, but it's not that easy to do. I'll most likely revisit it later.
*/

import { type ConfigPlugin, type InfoPlist, IOSConfig, withXcodeProject } from '@expo/config-plugins'
import plist from '@expo/plist'
import type { ExpoConfig } from 'expo/config'
import { readFileSync, writeFileSync } from 'fs'
import * as fs from 'fs/promises'
import * as path from 'path'

import { logger } from '../../utils'

const FONT_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2']

/**
* Plugin that adds custom fonts to the Live Activity extension.
*
* @param config - The Expo config
* @param fonts - Array of font file paths or directories
* @param targetName - Name of the Live Activity extension target
* @returns Modified config
*/
export const withFonts: ConfigPlugin<{ fonts: string[]; targetName: string }> = (config, { fonts, targetName }) => {
if (!fonts || fonts.length === 0) {
return config
}

config = addFontsToTarget(config, fonts, targetName)
config = addFontsToPlist(config, fonts, targetName)
return config
}

/**
* Adds font files to the Live Activity extension target.
* This ensures fonts are bundled with the extension and accessible at runtime.
*/
function addFontsToTarget(config: ExpoConfig, fonts: string[], targetName: string) {
return withXcodeProject(config, async (config) => {
const resolvedFonts = await resolveFontPaths(fonts, config.modRequest.projectRoot)
const project = config.modResults
const platformProjectRoot = config.modRequest.platformProjectRoot
const targetUuid = project.findTargetKey(targetName)

if (targetUuid == null) {
throw new Error(`Target ${targetName} not found in Xcode project. Report this issue to the Voltra team.`)
}

IOSConfig.XcodeUtils.ensureGroupRecursively(project, 'VoltraResources')

for (const font of resolvedFonts) {
const fontPath = path.relative(platformProjectRoot, font)

IOSConfig.XcodeUtils.addResourceFileToGroup({
filepath: fontPath,
groupName: 'VoltraResources',
project,
isBuildFile: true,
verbose: true,
targetUuid,
})

logger.info(`Added font file: ${path.basename(font)}`)
}

return config
})
}

/**
* Adds font filenames to the Live Activity extension's Info.plist UIAppFonts array.
* This makes iOS aware of the custom fonts and allows them to be used in SwiftUI.
*/
function addFontsToPlist(config: ExpoConfig, fonts: string[], targetName: string) {
return withXcodeProject(config, async (config) => {
const resolvedFonts = await resolveFontPaths(fonts, config.modRequest.projectRoot)
const platformProjectRoot = config.modRequest.platformProjectRoot

// Read the Live Activity extension's Info.plist directly
const targetPath = path.join(platformProjectRoot, targetName)
const infoPlistPath = path.join(targetPath, 'Info.plist')

try {
const plistContent = plist.parse(readFileSync(infoPlistPath, 'utf8')) as InfoPlist

// Get existing fonts or initialize empty array
const existingFonts = getUIAppFonts(plistContent)

// Add new fonts
const fontList = resolvedFonts.map((font) => path.basename(font))
const allFonts = [...existingFonts, ...fontList]
plistContent.UIAppFonts = Array.from(new Set(allFonts))

// Write back to file
writeFileSync(infoPlistPath, plist.build(plistContent))

logger.info(`Added ${fontList.length} font(s) to ${targetName} Info.plist`)
} catch (error) {
logger.warn(`Could not update Info.plist for fonts: ${error}`)
}

return config
})
}

/**
* Retrieves existing UIAppFonts from Info.plist.
*
* @param infoPlist - Info.plist object
* @returns Array of font filenames
*/
function getUIAppFonts(infoPlist: InfoPlist): string[] {
const fonts = infoPlist['UIAppFonts']
if (fonts != null && Array.isArray(fonts) && fonts.every((font) => typeof font === 'string')) {
return fonts as string[]
}
return []
}

/**
* Resolves font file paths from the provided array of paths or directories.
*
* This function:
* - Resolves relative paths to absolute paths
* - Expands directories to individual font files
* - Filters to only include valid font file extensions
*
* @param fonts - Array of font file paths or directories
* @param projectRoot - Project root directory
* @returns Promise resolving to array of absolute font file paths
*/
async function resolveFontPaths(fonts: string[], projectRoot: string): Promise<string[]> {
const promises = fonts.map(async (p) => {
const resolvedPath = path.resolve(projectRoot, p)

try {
const stat = await fs.stat(resolvedPath)

if (stat.isDirectory()) {
const dir = await fs.readdir(resolvedPath)
return dir.map((file) => path.join(resolvedPath, file))
}
return [resolvedPath]
} catch {
logger.warn(`Could not resolve font path: ${resolvedPath}`)
return []
}
})

const results = await Promise.all(promises)
return results.flat().filter((p) => FONT_EXTENSIONS.some((ext) => p.endsWith(ext)))
}
35 changes: 24 additions & 11 deletions plugin/src/features/ios/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConfigPlugin, withPlugins } from '@expo/config-plugins'
import type { WidgetConfig } from '../../types'
import { configureEasBuild } from './eas'
import { generateWidgetExtensionFiles } from './files'
import { withFonts } from './fonts'
import { configureMainAppPlist } from './plist'
import { configurePodfile } from './podfile'
import { configureXcodeProject } from './xcode'
Expand All @@ -13,35 +14,47 @@ export interface WithIOSProps {
deploymentTarget: string
widgets?: WidgetConfig[]
groupIdentifier?: string
fonts?: string[]
}

/**
* Main iOS configuration plugin.
*
* This orchestrates all iOS-related configuration in the correct order:
* 1. Generate widget extension files (Swift, assets, plist, entitlements)
* 2. Configure Xcode project (targets, build phases, groups)
* 3. Configure Podfile for widget extension
* 4. Configure main app Info.plist (URL schemes)
* 5. Configure EAS build settings
* 2. Add custom fonts (if provided)
* 3. Configure Xcode project (targets, build phases, groups)
* 4. Configure Podfile for widget extension
* 5. Configure main app Info.plist (URL schemes)
* 6. Configure EAS build settings
*
* NOTE: Expo mods execute in REVERSE registration order. Plugins that depend
* on modifications from other plugins must be registered BEFORE their dependencies.
* For example, fonts plugin needs the target created by configureXcodeProject,
* so fonts must be registered before configureXcodeProject.
*/
export const withIOS: ConfigPlugin<WithIOSProps> = (config, props) => {
const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier } = props
const { targetName, bundleIdentifier, deploymentTarget, widgets, groupIdentifier, fonts } = props

return withPlugins(config, [
const plugins: [ConfigPlugin<any>, any][] = [
// 1. Generate widget extension files (must run first so files exist)
[generateWidgetExtensionFiles, { targetName, widgets, groupIdentifier }],

// 2. Configure Xcode project (must run after files are generated)
// 2. Add custom fonts if provided
...(fonts && fonts.length > 0 ? [[withFonts, { fonts, targetName }] as [ConfigPlugin<any>, any]] : []),

// 3. Configure Xcode project (creates the target - must run before fonts mod executes)
[configureXcodeProject, { targetName, bundleIdentifier, deploymentTarget }],

// 3. Configure Podfile for widget extension target
// 4. Configure Podfile for widget extension target
[configurePodfile, { targetName }],

// 4. Configure main app Info.plist (URL schemes, widget extension plist)
// 5. Configure main app Info.plist (URL schemes, widget extension plist)
[configureMainAppPlist, { targetName, groupIdentifier }],

// 5. Configure EAS build settings
// 6. Configure EAS build settings
[configureEasBuild, { targetName, bundleIdentifier, groupIdentifier }],
])
]

return withPlugins(config, plugins)
}
9 changes: 1 addition & 8 deletions plugin/src/features/ios/xcode/build/phases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,5 @@ export function addBuildPhases(xcodeProject: XcodeProject, options: AddBuildPhas
xcodeProject.addBuildPhase([], 'PBXFrameworksBuildPhase', groupName, targetUuid, folderType, buildPath)

// Resources build phase
xcodeProject.addBuildPhase(
[...assetDirectories],
'PBXResourcesBuildPhase',
groupName,
targetUuid,
folderType,
buildPath
)
xcodeProject.addBuildPhase([...assetDirectories], 'PBXResourcesBuildPhase', 'Resources', targetUuid)
}
1 change: 1 addition & 0 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const withVoltra: VoltraConfigPlugin = (config, props = {}) => {
deploymentTarget,
widgets: props?.widgets,
...(props?.groupIdentifier ? { groupIdentifier: props.groupIdentifier } : {}),
...(props?.fonts ? { fonts: props.fonts } : {}),
})

// Optionally enable push notifications
Expand Down
10 changes: 10 additions & 0 deletions plugin/src/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export interface ConfigPluginProps {
* Useful for matching existing provisioning profiles or credentials
*/
targetName?: string
/**
* Custom fonts to include in the Live Activity extension.
* Provide an array of font file paths or directories containing fonts.
* Supports .ttf, .otf, .woff, and .woff2 formats.
*
* This is equivalent to expo-font but for the Live Activity extension.
* @see https://docs.expo.dev/versions/latest/sdk/font/
*/
fonts?: string[]
}

/**
Expand All @@ -48,4 +57,5 @@ export interface IOSPluginProps {
groupIdentifier?: string
projectRoot: string
platformProjectRoot: string
fonts?: string[]
}
Loading
Loading