Skip to content

Instantly share code, notes, and snippets.

@vonovak
Last active July 17, 2024 13:21
Show Gist options
  • Save vonovak/c8474aabc61c739fec4f7b7cd2b36599 to your computer and use it in GitHub Desktop.
Save vonovak/c8474aabc61c739fec4f7b7cd2b36599 to your computer and use it in GitHub Desktop.
RNVI with dynamic font loading
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