Last active
July 17, 2024 13:21
-
-
Save vonovak/c8474aabc61c739fec4f7b7cd2b36599 to your computer and use it in GitHub Desktop.
RNVI with dynamic font loading
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { forwardRef, type Ref, useEffect } from "react"; | |
import { PixelRatio, Platform, Text, type TextProps, type TextStyle, processColor } from 'react-native'; | |
import NativeIconAPI from './NativeVectorIcons'; | |
import createIconSourceCache from './create-icon-source-cache'; | |
import ensureNativeModuleAvailable from './ensure-native-module-available'; | |
export const DEFAULT_ICON_SIZE = 12; | |
export const DEFAULT_ICON_COLOR = 'black'; | |
export type IconProps<T> = TextProps & { | |
name: T; | |
size?: number; | |
color?: TextStyle['color']; | |
innerRef?: Ref<Text>; | |
allowDynamicFontLoading?: boolean; | |
}; | |
// @ts-ignore | |
globalThis.expo.modules.ExpoFontLoader.loadedCache ??= {}; | |
// console.log({mods: JSON.stringify(globalThis.expo.modules, null,2)}); | |
const getCache = (): { [name: string]: boolean } => { | |
// @ts-ignore | |
return globalThis.expo.modules.ExpoFontLoader.loadedCache; | |
}; | |
const isLoadedNative = (fontFamily: string) => { | |
const loadedCache = getCache(); | |
if (fontFamily in loadedCache) { | |
return true; | |
} else { | |
// @ts-ignore | |
const loadedNativeFonts: string[] = globalThis.expo.modules.ExpoFontLoader.loadedFonts; | |
loadedNativeFonts.forEach((font) => { | |
loadedCache[font] = true; | |
}); | |
return fontFamily in loadedCache; | |
} | |
}; | |
export const createIconSet = <GM extends Record<string, number>>( | |
glyphMap: GM, | |
fontFamily: string, | |
fontFile: string, | |
fontSource: number, | |
fontStyle?: TextProps['style'], | |
) => { | |
// Android doesn't care about actual fontFamily name, it will only look in fonts folder. | |
const fontBasename = fontFile ? fontFile.replace(/\.(otf|ttf)$/, '') : fontFamily; | |
// console.warn({fontFamily, isLoadedFont, loadedFonts: globalThis.expo.modules.ExpoFontLoader.loadedFonts}); | |
const fontReference = Platform.select({ | |
windows: `/Assets/${fontFile}#${fontFamily}`, | |
android: fontBasename, | |
web: fontBasename, | |
default: fontFamily, | |
}); | |
const resolveGlyph = (name: keyof GM) => { | |
const glyph = glyphMap[name] || '?'; | |
if (typeof glyph === 'number') { | |
return String.fromCodePoint(glyph); | |
} | |
return glyph; | |
}; | |
const Icon = ({ | |
name, | |
size = DEFAULT_ICON_SIZE, | |
color, | |
style, | |
children, | |
allowFontScaling = false, | |
innerRef, | |
allowDynamicFontLoading = true, // TODO this shouldn't be configurable on the Icon component level | |
...props | |
}: IconProps<keyof GM>) => { | |
const glyph = name ? resolveGlyph(name as string) : ''; | |
const [isFontLoaded, setIsFontLoaded] = React.useState(allowDynamicFontLoading ? isLoadedNative(fontFamily) : true); | |
useEffect(() => { | |
if (!isFontLoaded) { | |
downloadFontAsync(fontFamily, fontSource).finally(()=>{ | |
setIsFontLoaded(true); | |
}) | |
} | |
}, []); | |
if (!isFontLoaded) { | |
return <Text></Text> | |
} | |
const styleDefaults = { | |
fontSize: size, | |
color, | |
}; | |
const styleOverrides: TextProps['style'] = { | |
fontFamily: fontReference, | |
fontWeight: 'normal', | |
fontStyle: 'normal', | |
}; | |
const newProps: TextProps = { | |
...props, | |
style: [styleDefaults, style, styleOverrides, fontStyle || {}], | |
allowFontScaling, | |
}; | |
return ( | |
<Text ref={innerRef} selectable={false} {...newProps}> | |
{glyph} | |
{children} | |
</Text> | |
); | |
}; | |
const WrappedIcon = forwardRef<Text, IconProps<keyof typeof glyphMap>>((props, ref) => ( | |
<Icon innerRef={ref} {...props} /> | |
)); | |
WrappedIcon.displayName = 'Icon'; | |
const imageSourceCache = createIconSourceCache(); | |
const getImageSourceSync = ( | |
name: keyof GM, | |
size = DEFAULT_ICON_SIZE, | |
color: TextStyle['color'] = DEFAULT_ICON_COLOR, | |
) => { | |
ensureNativeModuleAvailable(); | |
const glyph = resolveGlyph(name); | |
const processedColor = processColor(color); | |
const cacheKey = `${glyph}:${size}:${String(processedColor)}`; | |
if (imageSourceCache.has(cacheKey)) { | |
// FIXME: Should this check if it's an error and throw it again? | |
return imageSourceCache.get(cacheKey); | |
} | |
try { | |
const imagePath = NativeIconAPI.getImageForFontSync( | |
fontReference, | |
glyph, | |
size, | |
processedColor as number, // FIXME what if a non existant colour was passed in? | |
); | |
const value = { uri: imagePath, scale: PixelRatio.get() }; | |
imageSourceCache.setValue(cacheKey, value); | |
return value; | |
} catch (error) { | |
imageSourceCache.setError(cacheKey, error as Error); | |
throw error; | |
} | |
}; | |
const getImageSource = async ( | |
name: keyof GM, | |
size = DEFAULT_ICON_SIZE, | |
color: TextStyle['color'] = DEFAULT_ICON_COLOR, | |
) => { | |
ensureNativeModuleAvailable(); | |
const glyph = resolveGlyph(name); | |
const processedColor = processColor(color); | |
const cacheKey = `${glyph}:${size}:${String(processedColor)}`; | |
if (imageSourceCache.has(cacheKey)) { | |
// FIXME: Should this check if it's an error and throw it again? | |
return imageSourceCache.get(cacheKey); | |
} | |
try { | |
const imagePath = await NativeIconAPI.getImageForFont( | |
fontReference, | |
glyph, | |
size, | |
processedColor as number, // FIXME what if a non existant colour was passed in? | |
); | |
const value = { uri: imagePath, scale: PixelRatio.get() }; | |
imageSourceCache.setValue(cacheKey, value); | |
return value; | |
} catch (error) { | |
imageSourceCache.setError(cacheKey, error as Error); | |
throw error; | |
} | |
}; | |
const loadFont = async () => { | |
if (Platform.OS !== 'ios') { | |
return; | |
} | |
ensureNativeModuleAvailable(); | |
const [filename, extension] = fontFile.split('.'); // FIXME: what if filename has two dots? | |
if (!filename) { | |
// NOTE: Thie is impossible but TypeScript doesn't know that | |
throw new Error('Font needs a filename.'); | |
} | |
if (!extension) { | |
throw new Error('Font needs a filename extensison.'); | |
} | |
await NativeIconAPI.loadFontWithFileName(filename, extension, 'react-native-vector-icons'); | |
}; | |
//node_modules/@react-native-vector-icons/ionicons/fonts/Ionicons.ttf | |
// loadFont(); | |
const IconNamespace = Object.assign(WrappedIcon, { | |
getImageSource, | |
getImageSourceSync, | |
}); | |
return IconNamespace; | |
}; | |
import { getAssetByID } from '@react-native/assets-registry/registry'; | |
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; | |
// TODO should this be a global cache? | |
export const loadPromises: { [fontSource: string]: Promise<void> } = {}; | |
type MetaType = { | |
name: string; | |
httpServerLocation: string; | |
hash: string; | |
type: string; // file extension | |
}; | |
type ResolvedAssetSource = { | |
__packager_asset: boolean; | |
width: number | null; | |
height: number | null; | |
uri: string; | |
scale: number; | |
} | |
export function getLocalFontUrl(fontSource: number) { | |
const meta: MetaType = getAssetByID(fontSource); | |
const assetSource: ResolvedAssetSource = resolveAssetSource(fontSource)!; | |
return { ...meta, ...assetSource }; | |
} | |
const downloadFontAsync = async (fontFamily: string, fontSource: number) => { | |
if (loadPromises.hasOwnProperty(fontFamily)) { | |
return loadPromises[fontFamily]; | |
} | |
loadPromises[fontFamily] = (async () => { | |
try { | |
const fontMeta = getLocalFontUrl(fontSource); | |
console.log({fontMeta}); | |
const { uri, type, hash, name } = fontMeta; | |
const localUri = await globalThis.expo.modules.ExpoAsset.downloadAsync(uri, hash, type); | |
console.log({localUri}); | |
await globalThis.expo.modules.ExpoFontLoader.loadAsync(name, localUri); | |
} catch (error) { | |
console.error('Failed to load font', error); | |
} finally { | |
delete loadPromises[fontFamily]; | |
} | |
})(); | |
return loadPromises[fontFamily] | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment