update: latest source changes

This commit is contained in:
2026-05-31 20:50:37 +02:00
parent 858674c9d4
commit d88187be16
156 changed files with 11885 additions and 4 deletions
+124
View File
@@ -0,0 +1,124 @@
import { requireNativeViewManager } from 'expo-modules-core';
import React from 'react';
import { NativeSyntheticEvent, StyleSheet, Platform, processColor } from 'react-native';
import {
ImageErrorEventData,
ImageLoadEventData,
ImageNativeProps,
ImageProgressEventData,
} from './Image.types';
const NativeExpoImage = requireNativeViewManager('ExpoImage');
function withDeprecatedNativeEvent<NativeEvent>(
event: NativeSyntheticEvent<NativeEvent>
): NativeEvent {
Object.defineProperty(event.nativeEvent, 'nativeEvent', {
get() {
console.warn(
'[expo-image]: Accessing event payload through "nativeEvent" is deprecated, it is now part of the event object itself'
);
return event.nativeEvent;
},
});
return event.nativeEvent;
}
class ExpoImage extends React.PureComponent<ImageNativeProps> {
// NOTE(@kitten): native methods
startAnimating!: () => Promise<unknown> | unknown;
stopAnimating!: () => Promise<unknown> | unknown;
lockResourceAsync!: () => Promise<void>;
unlockResourceAsync!: () => Promise<void>;
reloadAsync!: () => Promise<void>;
onLoadStart = () => {
this.props.onLoadStart?.();
};
onLoad = (event: NativeSyntheticEvent<ImageLoadEventData>) => {
this.props.onLoad?.(withDeprecatedNativeEvent(event));
this.onLoadEnd();
};
onProgress = (event: NativeSyntheticEvent<ImageProgressEventData>) => {
this.props.onProgress?.(withDeprecatedNativeEvent(event));
};
onError = (event: NativeSyntheticEvent<ImageErrorEventData>) => {
this.props.onError?.(withDeprecatedNativeEvent(event));
this.onLoadEnd();
};
onLoadEnd = () => {
this.props.onLoadEnd?.();
};
render() {
const { style, accessibilityLabel, alt, ...props } = this.props;
const resolvedStyle = StyleSheet.flatten(style);
// Shadows behave different on iOS, Android & Web.
// Android uses the `elevation` prop, whereas iOS
// and web use the regular `shadow...` props.
if (Platform.OS === 'android') {
delete resolvedStyle.shadowColor;
delete resolvedStyle.shadowOffset;
delete resolvedStyle.shadowOpacity;
delete resolvedStyle.shadowRadius;
} else {
// @ts-expect-error
delete resolvedStyle.elevation;
}
// @ts-ignore
const backgroundColor = processColor(resolvedStyle.backgroundColor);
// On Android, we have to set the `backgroundColor` directly on the correct component.
// So we have to remove it from styles. Otherwise, the background color won't take into consideration the border-radius.
if (Platform.OS === 'android') {
delete resolvedStyle.backgroundColor;
}
const tintColor = processColor(props.tintColor || resolvedStyle.tintColor);
const borderColor = processColor(resolvedStyle.borderColor);
// @ts-ignore
const borderStartColor = processColor(resolvedStyle.borderStartColor);
// @ts-ignore
const borderEndColor = processColor(resolvedStyle.borderEndColor);
// @ts-ignore
const borderLeftColor = processColor(resolvedStyle.borderLeftColor);
// @ts-ignore
const borderRightColor = processColor(resolvedStyle.borderRightColor);
// @ts-ignore
const borderTopColor = processColor(resolvedStyle.borderTopColor);
// @ts-ignore
const borderBottomColor = processColor(resolvedStyle.borderBottomColor);
return (
<NativeExpoImage
{...props}
{...resolvedStyle}
accessibilityLabel={accessibilityLabel ?? alt}
style={resolvedStyle}
onLoadStart={this.onLoadStart}
onLoad={this.onLoad}
onProgress={this.onProgress}
onError={this.onError}
tintColor={tintColor}
borderColor={borderColor}
borderLeftColor={borderLeftColor}
borderRightColor={borderRightColor}
borderTopColor={borderTopColor}
borderBottomColor={borderBottomColor}
borderStartColor={borderStartColor}
borderEndColor={borderEndColor}
backgroundColor={backgroundColor}
ref={props.nativeViewRef}
/>
);
}
}
export default ExpoImage;
+172
View File
@@ -0,0 +1,172 @@
import React from 'react';
// TODO(@kitten): We shouldn't be importing all of react-native-web or rely on it for a web module in this way optimally
import { View } from 'react-native-web';
import type { ImageNativeProps, ImageSource, ImageLoadEventData, ImageRef } from './Image.types';
import AnimationManager, { AnimationManagerNode } from './web/AnimationManager';
import ImageWrapper from './web/ImageWrapper';
import loadStyle from './web/imageStyles';
import useSourceSelection from './web/useSourceSelection';
loadStyle();
function onLoadAdapter(onLoad?: (event: ImageLoadEventData) => void) {
return (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
const target = event.target as HTMLImageElement;
onLoad?.({
source: {
url: target.currentSrc,
width: target.naturalWidth,
height: target.naturalHeight,
mediaType: null,
},
cacheType: 'none',
});
};
}
function onErrorAdapter(onError?: { (event: { error: string }): void }) {
return ({ source }: { source?: ImageSource | null }) => {
onError?.({
error: `Failed to load image from url: ${source?.uri}`,
});
};
}
// Used for flip transitions to mimic native animations
function setCssVariablesForFlipTransitions(element: HTMLElement, size: DOMRect) {
element?.style.setProperty('--expo-image-width', `${size.width}px`);
element?.style.setProperty('--expo-image-height', `${size.height}px`);
}
function isFlipTransition(transition: ImageNativeProps['transition']) {
return (
transition?.effect === 'flip-from-bottom' ||
transition?.effect === 'flip-from-top' ||
transition?.effect === 'flip-from-left' ||
transition?.effect === 'flip-from-right'
);
}
function getAnimationKey(
source: ImageSource | ImageRef | undefined,
recyclingKey?: string | null
): string {
const uri = (source && 'uri' in source && source.uri) || '';
return recyclingKey ? [recyclingKey, uri].join('-') : uri;
}
export default function ExpoImage({
source,
placeholder,
contentFit,
contentPosition,
placeholderContentFit,
cachePolicy,
onLoad,
transition,
onError,
responsivePolicy,
onLoadEnd,
onDisplay,
priority,
blurRadius,
recyclingKey,
style,
nativeViewRef,
accessibilityLabel,
alt,
tintColor,
containerViewRef,
...props
}: ImageNativeProps) {
const imagePlaceholderContentFit = placeholderContentFit || 'scale-down';
const imageHashStyle = {
objectFit: placeholderContentFit || contentFit,
};
const selectedSource = useSourceSelection(
source,
responsivePolicy,
// TODO(@vonovak): this cast is a workaround
containerViewRef as React.RefObject<HTMLDivElement | null>,
isFlipTransition(transition) ? setCssVariablesForFlipTransitions : null
);
// TODO(@kitten): This should narrow before accessing `placeholder?.[0]`
const firstPlaceholder = (placeholder as (typeof placeholder & ImageSource[]) | undefined)?.[0];
const initialNodeAnimationKey = getAnimationKey(firstPlaceholder, recyclingKey);
const initialNode: AnimationManagerNode | null = firstPlaceholder?.uri
? [
initialNodeAnimationKey,
({ onAnimationFinished }) =>
(className, style) => (
<ImageWrapper
ref={nativeViewRef as React.Ref<HTMLImageElement> | undefined}
source={firstPlaceholder}
style={{
objectFit: imagePlaceholderContentFit,
...(blurRadius ? { filter: `blur(${blurRadius}px)` } : {}),
...style,
}}
className={className}
events={{
onTransitionEnd: [onAnimationFinished],
}}
contentPosition={{ left: '50%', top: '50%' }}
hashPlaceholderContentPosition={contentPosition}
hashPlaceholderStyle={imageHashStyle}
accessibilityLabel={accessibilityLabel ?? alt}
cachePolicy={cachePolicy}
priority={priority}
tintColor={tintColor}
/>
),
]
: null;
// @ts-expect-error: TODO(@kitten): This was implicitly cast to `any`, but with correct types this is now a mismatch
const currentNodeAnimationKey = getAnimationKey(selectedSource ?? firstPlaceholder, recyclingKey);
const currentNode: AnimationManagerNode = [
currentNodeAnimationKey,
({ onAnimationFinished, onReady, onMount, onError: onErrorInner }) =>
(className, style) => (
<ImageWrapper
ref={nativeViewRef as React.Ref<HTMLImageElement> | undefined}
// @ts-expect-error: TODO(@kitten): This was implicitly cast to `any`, but with correct types this is now a mismatch
source={selectedSource || firstPlaceholder}
events={{
onError: [onErrorAdapter(onError), onLoadEnd, onErrorInner],
onLoad: [onLoadAdapter(onLoad), onLoadEnd, onReady],
onMount: [onMount],
onTransitionEnd: [onAnimationFinished],
onDisplay: [onDisplay],
}}
style={{
objectFit: selectedSource ? contentFit : imagePlaceholderContentFit,
...(blurRadius ? { filter: `blur(${blurRadius}px)` } : {}),
...style,
}}
className={className}
cachePolicy={cachePolicy}
priority={priority}
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
hashPlaceholderContentPosition={contentPosition}
hashPlaceholderStyle={imageHashStyle}
accessibilityLabel={accessibilityLabel}
tintColor={tintColor}
/>
),
];
return (
<View
ref={containerViewRef}
// @ts-expect-error: TODO(@kitten): This is related to react-native-web presumably
dataSet={{ expoimage: true }}
style={[{ overflow: 'hidden' }, style]}
{...props}>
<AnimationManager transition={transition} recyclingKey={recyclingKey} initial={initialNode}>
{currentNode}
</AnimationManager>
</View>
);
}
+262
View File
@@ -0,0 +1,262 @@
'use client';
import { Platform, createSnapshotFriendlyRef } from 'expo-modules-core';
import React from 'react';
import { StyleSheet, type View } from 'react-native';
import ExpoImage from './ExpoImage';
import {
ImageLoadOptions,
ImagePrefetchOptions,
ImageProps,
ImageRef,
ImageSource,
} from './Image.types';
import ImageModule from './ImageModule';
import { resolveContentFit, resolveContentPosition, resolveTransition } from './utils';
import { resolveSource, resolveSources } from './utils/resolveSources';
let loggedDefaultSourceDeprecationWarning = false;
let loggedRenderingChildrenWarning = false;
export class Image extends React.PureComponent<ImageProps> {
nativeViewRef: React.RefObject<ExpoImage | null>;
containerViewRef: React.RefObject<View | null>;
constructor(props: ImageProps) {
super(props);
this.nativeViewRef = createSnapshotFriendlyRef();
this.containerViewRef = createSnapshotFriendlyRef();
}
// Reanimated support on web
getAnimatableRef = () => {
if (Platform.OS === 'web') {
return this.containerViewRef.current;
} else {
return this;
}
};
/**
* @hidden
*/
static Image = ImageModule.Image;
/**
* Preloads images at the given URLs that can be later used in the image view.
* Preloaded images are cached to the memory and disk by default, so make sure
* to use `disk` (default) or `memory-disk` [cache policy](#cachepolicy).
* @param urls - A URL string or an array of URLs of images to prefetch.
* @param {ImagePrefetchOptions['cachePolicy']} cachePolicy - The cache policy for prefetched images.
* @return A promise resolving to `true` as soon as all images have been
* successfully prefetched. If an image fails to be prefetched, the promise
* will immediately resolve to `false` regardless of whether other images have
* finished prefetching.
*/
static async prefetch(
urls: string | string[],
cachePolicy?: ImagePrefetchOptions['cachePolicy']
): Promise<boolean>;
/**
* Preloads images at the given URLs that can be later used in the image view.
* Preloaded images are cached to the memory and disk by default, so make sure
* to use `disk` (default) or `memory-disk` [cache policy](#cachepolicy).
* @param urls - A URL string or an array of URLs of images to prefetch.
* @param options - Options for prefetching images.
* @return A promise resolving to `true` as soon as all images have been
* successfully prefetched. If an image fails to be prefetched, the promise
* will immediately resolve to `false` regardless of whether other images have
* finished prefetching.
*/
static async prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise<boolean>;
static async prefetch(
urls: string | string[],
options?: ImagePrefetchOptions['cachePolicy'] | ImagePrefetchOptions
): Promise<boolean> {
let cachePolicy: ImagePrefetchOptions['cachePolicy'] = 'memory-disk';
let headers: ImagePrefetchOptions['headers'];
switch (typeof options) {
case 'string':
cachePolicy = options;
break;
case 'object':
cachePolicy = options.cachePolicy ?? cachePolicy;
headers = options.headers;
break;
}
return ImageModule.prefetch(Array.isArray(urls) ? urls : [urls], cachePolicy, headers);
}
/**
* Asynchronously clears all images stored in memory.
* @platform android
* @platform ios
* @return A promise resolving to `true` when the operation succeeds.
* It may resolve to `false` on Android when the activity is no longer available.
* Resolves to `false` on Web.
*/
static async clearMemoryCache(): Promise<boolean> {
return await ImageModule.clearMemoryCache();
}
/**
* Asynchronously clears all images from the disk cache.
* @platform android
* @platform ios
* @return A promise resolving to `true` when the operation succeeds.
* It may resolve to `false` on Android when the activity is no longer available.
* Resolves to `false` on Web.
*/
static async clearDiskCache(): Promise<boolean> {
return await ImageModule.clearDiskCache();
}
/**
* Asynchronously checks if an image exists in the disk cache and resolves to
* the path of the cached image if it does.
* @param cacheKey - The cache key for the requested image. Unless you have set
* a custom cache key, this will be the source URL of the image.
* @platform android
* @platform ios
* @return A promise resolving to the path of the cached image. It will resolve
* to `null` if the image does not exist in the cache.
*/
static async getCachePathAsync(cacheKey: string): Promise<string | null> {
return await ImageModule.getCachePathAsync(cacheKey);
}
/**
* Asynchronously generates a [Blurhash](https://blurha.sh) from an image.
* @param source - The image source, either a URL (string) or an ImageRef
* @param numberOfComponents - The number of components to encode the blurhash with.
* Must be between 1 and 9. Defaults to `[4, 3]`.
* @platform android
* @platform ios
* @return A promise resolving to the blurhash string.
*/
static async generateBlurhashAsync(
source: string | ImageRef,
numberOfComponents: [number, number] | { width: number; height: number }
): Promise<string | null> {
return ImageModule.generateBlurhashAsync(source, numberOfComponents);
}
/**
* Asynchronously generates a [Thumbhash](https://evanw.github.io/thumbhash/) from an image.
* @param source - The image source, either a URL (string) or an ImageRef
* @platform android
* @platform ios
* @return A promise resolving to the thumbhash string.
*/
static async generateThumbhashAsync(source: string | ImageRef): Promise<string> {
return ImageModule.generateThumbhashAsync(source);
}
/**
* Asynchronously starts playback of the view's image if it is animated.
* @platform android
* @platform ios
*/
async startAnimating(): Promise<void> {
await this.nativeViewRef.current?.startAnimating();
}
/**
* Asynchronously stops the playback of the view's image if it is animated.
* @platform android
* @platform ios
*/
async stopAnimating(): Promise<void> {
await this.nativeViewRef.current?.stopAnimating();
}
/**
* Prevents the resource from being reloaded by locking it.
* @platform android
* @platform ios
*/
async lockResourceAsync(): Promise<void> {
await this.nativeViewRef.current?.lockResourceAsync();
}
/**
* Releases the lock on the resource, allowing it to be reloaded.
* @platform android
* @platform ios
*/
async unlockResourceAsync(): Promise<void> {
await this.nativeViewRef.current?.unlockResourceAsync();
}
/**
* Reloads the resource, ignoring lock.
* @platform android
* @platform ios
*/
async reloadAsync(): Promise<void> {
await this.nativeViewRef.current?.reloadAsync();
}
/**
* Loads an image from the given source to memory and resolves to
* an object that references the native image instance.
* @platform android
* @platform ios
* @platform web
*/
static async loadAsync(
source: ImageSource | string | number,
options?: ImageLoadOptions
): Promise<ImageRef> {
const resolvedSource = resolveSource(source) as ImageSource;
return await ImageModule.loadAsync(resolvedSource, options);
}
render() {
const {
style,
source,
placeholder,
contentFit,
contentPosition,
transition,
fadeDuration,
resizeMode: resizeModeProp,
defaultSource,
loadingIndicatorSource,
...restProps
} = this.props;
const { resizeMode: resizeModeStyle, ...restStyle } = StyleSheet.flatten(style) || {};
const resizeMode = resizeModeProp ?? resizeModeStyle;
if ((defaultSource || loadingIndicatorSource) && !loggedDefaultSourceDeprecationWarning) {
console.warn(
'[expo-image]: `defaultSource` and `loadingIndicatorSource` props are deprecated, use `placeholder` instead'
);
loggedDefaultSourceDeprecationWarning = true;
}
// @ts-expect-error
if (restProps.children && !loggedRenderingChildrenWarning) {
console.warn(
'The <Image> component does not support children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.'
);
loggedRenderingChildrenWarning = true;
}
return (
<ExpoImage
{...restProps}
style={restStyle}
source={resolveSources(source)}
placeholder={resolveSources(placeholder ?? defaultSource ?? loadingIndicatorSource)}
contentFit={resolveContentFit(contentFit, resizeMode)}
contentPosition={resolveContentPosition(contentPosition)}
transition={resolveTransition(transition, fadeDuration)}
nativeViewRef={this.nativeViewRef}
containerViewRef={this.containerViewRef}
/>
);
}
}
+613
View File
@@ -0,0 +1,613 @@
import type { NativeModule, SharedRef, SharedRefType } from 'expo';
import { ImageStyle as RNImageStyle, StyleProp, View, ViewProps, ViewStyle } from 'react-native';
import ExpoImage from './ExpoImage';
export type ImageSource = {
/**
* A string representing the resource identifier for the image,
* which could be an HTTPS address, a local file path, or the name of a static image resource.
*/
uri?: string;
/**
* An object representing the HTTP headers to send along with the request for a remote image.
* On web requires the `Access-Control-Allow-Origin` header returned by the server to include the current domain.
*/
headers?: Record<string, string>;
/**
* Can be specified if known at build time, in which case the value
* will be used to set the default `<Image/>` component dimension.
*/
width?: number | null;
/**
* Can be specified if known at build time, in which case the value
* will be used to set the default `<Image/>` component dimension.
*/
height?: number | null;
/**
* A string used to generate the image [`placeholder`](#placeholder). For example,
* `placeholder={blurhash}`. If `uri` is provided as the value of the `source` prop,
* this is ignored since the `source` can only have `blurhash` or `uri`.
*
* When using the blurhash, you should also provide `width` and `height` (higher values reduce performance),
* otherwise their default value is `16`.
* For more information, see [`woltapp/blurhash`](https://github.com/woltapp/blurhash) repository.
*/
blurhash?: string;
/**
* A string used to generate the image [`placeholder`](#placeholder). For example,
* `placeholder={thumbhash}`. If `uri` is provided as the value of the `source` prop,
* this is ignored since the `source` can only have `thumbhash` or `uri`.
*
* For more information, see [`thumbhash website`](https://evanw.github.io/thumbhash/).
*/
thumbhash?: string;
/**
* The cache key used to query and store this specific image.
* If not provided, the `uri` is used also as the cache key.
*/
cacheKey?: string;
/**
* The max width of the viewport for which this source should be selected.
* Has no effect if `source` prop is not an array or has only 1 element.
* Has no effect if `responsivePolicy` is not set to `static`.
* Ignored if `blurhash` or `thumbhash` is provided (image hashes are never selected if passed in an array).
* @platform web
*/
webMaxViewportWidth?: number;
/**
* Whether the image is animated (an animated GIF or WebP for example).
* @platform android
* @platform ios
*/
isAnimated?: boolean;
};
/**
* @hidden
*/
export type ImageStyle = RNImageStyle;
/**
* Determines how the image should be resized to fit its container.
* @hidden Described in the {@link ImageProps['contentFit']}
*/
export type ImageContentFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
/**
* Determines which format should be used to decode the image.
* It's suggestion for the platform to use the specified format, but it's not guaranteed.
* @hidden Described in the {@link ImageProps['decodeFormat']}
*/
export type ImageDecodeFormat = 'argb' | 'rgb';
/**
* Some props are from React Native Image that Expo Image supports (more or less) for easier migration,
* but all of them are deprecated and might be removed in the future.
*/
export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
/** @hidden */
style?: StyleProp<RNImageStyle>;
/**
* The image source, either a remote URL, a local file resource or a number that is the result of the `require()` function.
* When provided as an array of sources, the source that fits best into the container size and is closest to the screen scale
* will be chosen. In this case it is important to provide `width`, `height` and `scale` properties.
*/
source?: ImageSource | string | number | ImageSource[] | string[] | SharedRefType<'image'> | null;
/**
* An image to display while loading the proper image and no image has been displayed yet or the source is unset.
*
* > **Note**: The default value for placeholder's content fit is 'scale-down', which differs from the source image's default value.
* > Using a lower-resolution placeholder may cause flickering due to scaling differences between it and the final image.
* > To prevent this, you can set the [`placeholderContentFit`](#placeholdercontentfit) to match the [`contentFit`](#contentfit) value.
*/
placeholder?:
| ImageSource
| string
| number
| ImageSource[]
| string[]
| SharedRefType<'image'>
| null;
/**
* Determines how the image should be resized to fit its container. This property tells the image to fill the container
* in a variety of ways; such as "preserve that aspect ratio" or "stretch up and take up as much space as possible".
* It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
*
* - `'cover'` - The image is sized to maintain its aspect ratio while filling the container box.
* If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
*
* - `'contain'` - The image is scaled down or up to maintain its aspect ratio while fitting within the container box.
*
* - `'fill'` - The image is sized to entirely fill the container box. If necessary, the image will be stretched or squished to fit.
*
* - `'none'` - The image is not resized and is centered by default.
* When specified, the exact position can be controlled with [`contentPosition`](#contentposition) prop.
*
* - `'scale-down'` - The image is sized as if `none` or `contain` were specified, whichever would result in a smaller concrete image size.
*
* @default 'cover'
*/
contentFit?: ImageContentFit;
/**
* Determines how the placeholder should be resized to fit its container. Available resize modes are the same as for the [`contentFit`](#contentfit) prop.
* @default 'scale-down'
*/
placeholderContentFit?: ImageContentFit;
/**
* It is used together with [`contentFit`](#contentfit) to specify how the image should be positioned with x/y coordinates inside its own container.
* An equivalent of the CSS [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) property.
* @default 'center'
*/
contentPosition?: ImageContentPosition;
/**
* Describes how the image view should transition the contents when switching the image source.\
* If provided as a number, it is the duration in milliseconds of the `'cross-dissolve'` effect.
*/
transition?: ImageTransition | number | null;
/**
* The radius of the blur in points, `0` means no blur effect.
* This effect is not applied to placeholders.
* @default 0
*/
blurRadius?: number;
/**
* A color used to tint template images (a bitmap image where only the opacity matters).
* The color is applied to every non-transparent pixel, causing the image's shape to adopt that color.
* This effect is not applied to placeholders.
* @default null
*/
tintColor?: string | null;
/**
* Priorities for completing loads. If more than one load is queued at a time,
* the load with the higher priority will be started first.
* Priorities are considered best effort, there are no guarantees about the order in which loads will start or finish.
* @default 'normal'
*/
priority?: 'low' | 'normal' | 'high' | null;
/**
* Determines whether to cache the image and where: on the disk, in the memory or both.
*
* - `'none'` - Image is not cached at all.
*
* - `'disk'` - Image is queried from the disk cache if exists, otherwise it's downloaded and then stored on the disk.
*
* - `'memory'` - Image is cached in memory. Might be useful when you render a high-resolution picture many times.
* Memory cache may be purged very quickly to prevent high memory usage and the risk of out of memory exceptions.
*
* - `'memory-disk'` - Image is cached in memory, but with a fallback to the disk cache.
*
* @default 'disk'
*/
cachePolicy?: 'none' | 'disk' | 'memory' | 'memory-disk' | /** @hidden */ null;
/**
* Controls the selection of the image source based on the container or viewport size on the web.
*
* If set to `'static'`, the browser selects the correct source based on user's viewport width. Works with static rendering.
* Make sure to set the `'webMaxViewportWidth'` property on each source for best results.
* For example, if an image occupies 1/3 of the screen width, set the `'webMaxViewportWidth'` to 3x the image width.
* The source with the largest `'webMaxViewportWidth'` is used even for larger viewports.
*
* If set to `'initial'`, the component will select the correct source during mount based on container size. Does not work with static rendering.
*
* If set to `'live'`, the component will select the correct source on every resize based on container size. Does not work with static rendering.
*
* @default 'static'
* @platform web
*/
responsivePolicy?: 'live' | 'initial' | 'static';
/**
* Changing this prop resets the image view content to blank or a placeholder before loading and rendering the final image.
* This is especially useful for any kinds of recycling views like [FlashList](https://github.com/shopify/flash-list)
* to prevent showing the previous source before the new one fully loads.
* @default null
* @platform android
* @platform ios
*/
recyclingKey?: string | null;
/**
* Determines if an image should automatically begin playing if it is an
* animated image.
* @default true
* @platform android
* @platform ios
*/
autoplay?: boolean;
/**
* Called when the image starts to load.
*/
onLoadStart?: () => void;
/**
* Called when the image load completes successfully.
*/
onLoad?: (event: ImageLoadEventData) => void;
/**
* Called when the image is loading. Can be called multiple times before the image has finished loading.
* The event object provides details on how many bytes were loaded so far and what's the expected total size.
*/
onProgress?: (event: ImageProgressEventData) => void;
/**
* Called on an image fetching error.
*/
onError?: (event: ImageErrorEventData) => void;
/**
* Called when the image load either succeeds or fails.
*/
onLoadEnd?: () => void;
/**
* Called when the image view successfully rendered the source image.
*/
onDisplay?: () => void;
// DEPRECATED
/**
* @deprecated Provides compatibility for [`defaultSource` from React Native Image](https://reactnative.dev/docs/image#defaultsource).
* Use [`placeholder`](#placeholder) prop instead.
*/
defaultSource?: ImageSource | null;
/**
* @deprecated Provides compatibility for [`loadingIndicatorSource` from React Native Image](https://reactnative.dev/docs/image#loadingindicatorsource).
* Use [`placeholder`](#placeholder) prop instead.
*/
loadingIndicatorSource?: ImageSource | null;
/**
* @deprecated Provides compatibility for [`resizeMode` from React Native Image](https://reactnative.dev/docs/image#resizemode).
* Note that `"repeat"` option is not supported at all.
* Use the more powerful [`contentFit`](#contentfit) and [`contentPosition`](#contentposition) props instead.
*/
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
/**
* @deprecated Provides compatibility for [`fadeDuration` from React Native Image](https://reactnative.dev/docs/image#fadeduration-android).
* Instead use [`transition`](#transition) with the provided duration.
*/
fadeDuration?: number;
/**
* Whether this View should be focusable with a non-touch input device and receive focus with a hardware keyboard.
* @default false
* @platform android
*/
focusable?: boolean;
/**
* When true, indicates that the view is an accessibility element.
* When a view is an accessibility element, it groups its children into a single selectable component.
*
* On Android, the `accessible` property will be translated into the native `isScreenReaderFocusable`,
* so it's only affecting the screen readers behaviour.
* @default false
* @platform android
* @platform ios
*/
accessible?: boolean;
/**
* The text that's read by the screen reader when the user interacts with the image. Sets the `alt` tag on web which is used for web crawlers and link traversal.
* @default undefined
*/
accessibilityLabel?: string;
/**
* The text that's read by the screen reader when the user interacts with the image. Sets the `alt` tag on web which is used for web crawlers and link traversal. Is an alias for `accessibilityLabel`.
*
* @alias accessibilityLabel
* @default undefined
*/
alt?: string;
/**
* Enables Live Text interaction with the image. Check official [Apple documentation](https://developer.apple.com/documentation/visionkit/enabling_live_text_interactions_with_images) for more details.
* @default false
* @platform ios 16.0+
*/
enableLiveTextInteraction?: boolean;
/**
* Whether the image should be downscaled to match the size of the view container.
* Turning off this functionality could negatively impact the application's performance, particularly when working with large assets.
* However, it would result in smoother image resizing, and end-users would always have access to the highest possible asset quality.
*
* Downscaling is never used when the `contentFit` prop is set to `none` or `fill`.
* @default true
*/
allowDownscaling?: boolean;
/**
* The format in which the image data should be decoded.
* It's not guaranteed that the platform will use the specified format.
*
* - `'argb'` - The image is decoded into a 32-bit color space with alpha channel (https://developer.android.com/reference/android/graphics/Bitmap.Config#ARGB_8888).
*
* - `'rgb'` - The image is decoded into a 16-bit color space without alpha channel (https://developer.android.com/reference/android/graphics/Bitmap.Config#RGB_565).
*
* @default 'argb'
* @platform android
*/
decodeFormat?: ImageDecodeFormat;
/**
* Whether to use the Apple's default WebP codec.
*
* Set this prop to `false` to use the official standard-compliant [libwebp](https://github.com/webmproject/libwebp) codec for WebP images.
* The default implementation from Apple is faster and uses less memory but may render animated images with incorrect blending or play them at the wrong framerate.
* @see https://github.com/SDWebImage/SDWebImage/wiki/Advanced-Usage#awebp-coder
*
* @default true
* @platform ios
*/
useAppleWebpCodec?: boolean;
/**
* Force early resizing of the image to match the container size.
* This option helps to reduce the memory usage of the image view, especially when the image is larger than the container.
* It may affect the `resizeType` and `contentPosition` properties when the image view is resized dynamically.
*
* @default false
* @platform ios
*/
enforceEarlyResizing?: boolean;
}
/**
* It narrows down some props to types expected by the native/web side.
* @hidden
*/
export interface ImageNativeProps extends ImageProps {
style?: RNImageStyle;
source?: ImageSource[] | SharedRefType<'image'>;
placeholder?: ImageSource[] | SharedRefType<'image'>;
contentPosition?: ImageContentPositionObject;
transition?: ImageTransition | null;
autoplay?: boolean;
nativeViewRef?: React.RefObject<ExpoImage | null>;
containerViewRef?: React.RefObject<View | null>;
}
/**
* A value that represents the relative position of a single axis.
*
* If `number`, it is a distance in points (logical pixels) from the respective edge.\
* If `string`, it must be a percentage value where `'100%'` is the difference in size between the container and the image along the respective axis,
* or `'center'` which is an alias for `'50%'` that is the default value. You can read more regarding percentages on the MDN docs for
* [`background-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-position#regarding_percentages) that describes this concept well.
*/
export type ImageContentPositionValue = number | string | `${number}%` | `${number}` | 'center';
/**
* Specifies the position of the image inside its container. One value controls the x-axis and the second value controls the y-axis.
*
* Additionally, it supports stringified shorthand form that specifies the edges to which to align the image content:\
* `'center'`, `'top'`, `'right'`, `'bottom'`, `'left'`, `'top center'`, `'top right'`, `'top left'`, `'right center'`, `'right top'`,
* `'right bottom'`, `'bottom center'`, `'bottom right'`, `'bottom left'`, `'left center'`, `'left top'`, `'left bottom'`.\
* If only one keyword is provided, then the other dimension is set to `'center'` (`'50%'`), so the image is placed in the middle of the specified edge.\
* As an example, `'top right'` is the same as `{ top: 0, right: 0 }` and `'bottom'` is the same as `{ bottom: 0, left: '50%' }`.
*/
export type ImageContentPosition =
/**
* An object that positions the image relatively to the top-right corner.
*/
| {
top?: ImageContentPositionValue;
right?: ImageContentPositionValue;
}
/**
* An object that positions the image relatively to the top-left corner.
*/
| {
top?: ImageContentPositionValue;
left?: ImageContentPositionValue;
}
/**
* An object that positions the image relatively to the bottom-right corner.
*/
| {
bottom?: ImageContentPositionValue;
right?: ImageContentPositionValue;
}
/**
* An object that positions the image relatively to the bottom-left corner.
*/
| {
bottom?: ImageContentPositionValue;
left?: ImageContentPositionValue;
}
| ImageContentPositionString;
/**
* It allows you to use an image as a background while rendering other content on top of it.
* It extends all `Image` props but provides separate styling controls for the container and the background image itself.
*/
export interface ImageBackgroundProps extends Omit<ImageProps, 'style'> {
/** The style of the image container. */
style?: StyleProp<ViewStyle> | undefined;
/** Style object for the image. */
imageStyle?: StyleProp<RNImageStyle> | undefined;
/** @hidden */
children?: React.ReactNode | undefined;
}
/**
* @hidden It's described as part of {@link ImageContentPosition}.
*/
export type ImageContentPositionString =
| 'center'
| 'top'
| 'right'
| 'bottom'
| 'left'
| 'top center'
| 'top right'
| 'top left'
| 'right center'
| 'right top'
| 'right bottom'
| 'bottom center'
| 'bottom right'
| 'bottom left'
| 'left center'
| 'left top'
| 'left bottom';
type OnlyObject<T> = T extends object ? T : never;
/**
* @hidden It's a conditional type that matches only objects of {@link ImageContentPosition}.
*/
export type ImageContentPositionObject = OnlyObject<ImageContentPosition>;
/**
* An object that describes the smooth transition when switching the image source.
*/
export type ImageTransition = {
/**
* The duration of the transition in milliseconds.
* @default 0
*/
duration?: number;
/**
* Specifies the speed curve of the transition effect and how intermediate values are calculated.
* @default 'ease-in-out'
*/
timing?: 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
/**
* An animation effect used for transition.
* @default 'cross-dissolve'
*
* On Android, only `'cross-dissolve'` is supported.
* On Web, `'curl-up'` and `'curl-down'` effects are not supported.
*/
effect?:
| 'cross-dissolve'
| 'flip-from-top'
| 'flip-from-right'
| 'flip-from-bottom'
| 'flip-from-left'
| 'curl-up'
| 'curl-down'
| null;
};
export type ImageLoadEventData = {
cacheType: 'none' | 'disk' | 'memory';
source: {
url: string;
width: number;
height: number;
mediaType: string | null;
isAnimated?: boolean;
};
};
export type ImageProgressEventData = {
loaded: number;
total: number;
};
export type ImageErrorEventData = {
error: string;
};
export type ImagePrefetchOptions = {
/**
* The cache policy for prefetched images.
* @default 'memory-disk'
*/
cachePolicy?: 'disk' | 'memory-disk' | 'memory';
/**
* A map of headers to use when prefetching the images.
*/
headers?: Record<string, string>;
};
/**
* An object that is a reference to a native image instance [Drawable](https://developer.android.com/reference/android/graphics/drawable/Drawable)
* on Android and [UIImage](https://developer.apple.com/documentation/uikit/uiimage) on iOS.
* Instances of this class can be passed as a source to the [Image](#image) component in which case the image is rendered immediately
* since its native representation is already available in the memory.
*/
export declare class ImageRef extends SharedRef<'image'> {
/**
* Logical width of the image. Multiply it by the value in the `scale` property to get the width in pixels.
*/
readonly width: number;
/**
* Logical height of the image. Multiply it by the value in the `scale` property to get the height in pixels.
*/
readonly height: number;
/**
* On iOS, if you load an image from a file whose name includes the `@2x` modifier, the scale is set to **2.0**. All other images are assumed to have a scale factor of **1.0**.
* On Android, it calculates the scale based on the bitmap density divided by screen density.
*
* On all platforms, if you multiply the logical size of the image by this value, you get the dimensions of the image in pixels.
*/
readonly scale: number;
/**
* Media type (also known as MIME type) of the image, based on its format.
* Returns `null` when the format is unknown or not supported.
* @platform ios
*/
readonly mediaType: string | null;
/**
* Whether the referenced image is an animated image.
*/
readonly isAnimated?: boolean;
}
/**
* @hidden
*/
export declare class ImageNativeModule extends NativeModule {
// TODO: Add missing function declarations
Image: typeof ImageRef;
loadAsync(source: ImageSource, options?: ImageLoadOptions): Promise<ImageRef>;
}
/**
* An object with options for the [`useImage`](#useimage) hook.
*/
export type ImageLoadOptions = {
/**
* If provided, the image will be automatically resized to not exceed this width in pixels, preserving its aspect ratio.
*/
maxWidth?: number;
/**
* If provided, the image will be automatically resized to not exceed this height in pixels, preserving its aspect ratio.
*/
maxHeight?: number;
/**
* Function to call when the image has failed to load. In addition to the error, it also provides a function that retries loading the image.
*/
onError?(error: Error, retry: () => void): void;
};
+16
View File
@@ -0,0 +1,16 @@
'use client';
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Image } from './Image';
import { ImageBackgroundProps } from './Image.types';
export function ImageBackground({ style, imageStyle, children, ...props }: ImageBackgroundProps) {
return (
<View style={style}>
<Image {...props} style={[StyleSheet.absoluteFill, imageStyle]} />
{children}
</View>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo';
import type { ImageNativeModule } from './Image.types';
export default requireNativeModule<ImageNativeModule>('ExpoImage');
+76
View File
@@ -0,0 +1,76 @@
import { NativeModule, registerWebModule } from 'expo-modules-core';
import type { ImageNativeModule, ImageRef, ImageSource } from './Image.types';
import ImageRefWeb from './web/ImageRef';
class ImageModule extends NativeModule implements ImageNativeModule {
Image: typeof ImageRef = ImageRefWeb;
async prefetch(urls: string | string[], _: unknown, __: unknown): Promise<boolean> {
const urlsArray = Array.isArray(urls) ? urls : [urls];
return new Promise<boolean>((resolve) => {
let imagesLoaded = 0;
urlsArray.forEach((url) => {
const img = new Image();
img.src = url;
img.onload = () => {
imagesLoaded++;
if (imagesLoaded === urlsArray.length) {
resolve(true);
}
};
img.onerror = () => resolve(false);
});
});
}
async clearMemoryCache(): Promise<boolean> {
return false;
}
async clearDiskCache(): Promise<boolean> {
return false;
}
async loadAsync(source: ImageSource): Promise<ImageRef> {
if (!source.uri) {
// TODO: Add support for sources without the uri, e.g. blurhash and thumbhash.
throw new Error('The image source must have the "uri" property defined');
}
const response = await fetch(source.uri, {
headers: source.headers,
});
if (!response.ok) {
throw new Error(`Image request failed with the status code: ${response.status}`);
}
const blob = await response.blob();
const imageObjectUrl = URL.createObjectURL(blob);
const image = await loadImageElementAsync(imageObjectUrl);
return ImageRefWeb.init(
imageObjectUrl,
image.width,
image.height,
response.headers.get('Content-Type')
);
}
}
/**
* Helper that resolves to an `<img />` element once it finishes loading the given source.
*/
async function loadImageElementAsync(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = document.createElement('img');
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Unable to load the image from '${src}'`));
image.src = src;
});
}
export default registerWebModule(ImageModule, 'ExpoImage');
+4
View File
@@ -0,0 +1,4 @@
export * from './Image.types';
export { Image } from './Image';
export { ImageBackground } from './ImageBackground';
export { useImage } from './useImage';
+1
View File
@@ -0,0 +1 @@
/// <reference path="../../../expo-asset/src/ts-declarations/react-native-assets.d.ts" />
+3
View File
@@ -0,0 +1,3 @@
declare module 'react-native-web' {
export { View } from 'react-native';
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import { DependencyList, useEffect, useRef, useState } from 'react';
import { Image } from './Image';
import type { ImageLoadOptions, ImageRef, ImageSource } from './Image.types';
import { resolveSource } from './utils/resolveSources';
/**
* A hook that loads an image from the given source and returns a reference
* to the native image instance, or `null` until the first image is successfully loaded.
*
* It loads a new image every time the `uri` of the provided source changes.
* To trigger reloads in some other scenarios, you can provide an additional dependency list.
* @platform android
* @platform ios
* @platform web
*
* @example
* ```ts
* import { useImage, Image } from 'expo-image';
* import { Text } from 'react-native';
*
* export default function MyImage() {
* const image = useImage('https://picsum.photos/1000/800', {
* maxWidth: 800,
* onError(error, retry) {
* console.error('Loading failed:', error.message);
* }
* });
*
* if (!image) {
* return <Text>Image is loading...</Text>;
* }
*
* return <Image source={image} style={{ width: image.width / 2, height: image.height / 2 }} />;
* }
* ```
*/
export function useImage(
source: ImageSource | string | number,
options: ImageLoadOptions = {},
dependencies: DependencyList = []
): ImageRef | null {
const resolvedSource = resolveSource(source) as ImageSource;
const [image, setImage] = useState<ImageRef | null>(null);
// Since options are not dependencies of the below effect, we store them in a ref.
// Once the image is asynchronously loaded, the effect will use the most recent options,
// instead of the captured ones (especially important for callbacks that may change in subsequent renders).
const optionsRef = useRef<ImageLoadOptions>(options);
optionsRef.current = options;
useEffect(() => {
// We're doing some asynchronous action in this effect, so we should keep track
// if the effect was already cleaned up. In that case, the async action shouldn't change the state.
let isEffectValid = true;
function loadImage() {
Image.loadAsync(resolvedSource, options)
.then((image) => {
if (isEffectValid) {
setImage(image);
}
})
.catch((error) => {
if (!isEffectValid) {
return;
}
if (optionsRef.current.onError) {
optionsRef.current.onError(error, loadImage);
} else {
// Print unhandled errors to the console.
console.error(
`Loading an image from '${resolvedSource.uri}' failed, use 'onError' option to handle errors and suppress this message`
);
console.error(error);
}
});
}
loadImage();
return () => {
// Invalidate the effect and release the shared object to free up memory.
isEffectValid = false;
image?.release();
};
}, [resolvedSource.uri, ...dependencies]);
return image;
}
+126
View File
@@ -0,0 +1,126 @@
import { SharedRef } from 'expo';
import type { SharedRefType } from 'expo';
import { type ImageResizeMode } from 'react-native';
import {
ImageContentFit,
ImageContentPosition,
ImageContentPositionObject,
ImageContentPositionString,
ImageProps,
ImageTransition,
} from './Image.types';
let loggedResizeModeDeprecationWarning = false;
let loggedRepeatDeprecationWarning = false;
let loggedFadeDurationDeprecationWarning = false;
/**
* If the `contentFit` is not provided, it's resolved from the equivalent `resizeMode` prop
* that we support to provide compatibility with React Native Image.
*/
export function resolveContentFit(
contentFit?: ImageContentFit,
resizeMode?: ImageResizeMode
): ImageContentFit {
if (contentFit) {
return contentFit;
}
if (resizeMode) {
if (!loggedResizeModeDeprecationWarning) {
console.log('[expo-image]: Prop "resizeMode" is deprecated, use "contentFit" instead');
loggedResizeModeDeprecationWarning = true;
}
switch (resizeMode) {
case 'contain':
case 'cover':
case 'none':
return resizeMode;
case 'stretch':
return 'fill';
case 'center':
return 'scale-down';
case 'repeat':
if (!loggedRepeatDeprecationWarning) {
console.log('[expo-image]: Resize mode "repeat" is no longer supported');
loggedRepeatDeprecationWarning = true;
}
return 'cover';
default: {
const exhaustiveCheck: never = resizeMode;
throw new Error(`Unhandled resizeMode case: ${exhaustiveCheck}`);
}
}
}
return 'cover';
}
/**
* It resolves a stringified form of the `contentPosition` prop to an object,
* which is the only form supported in the native code.
*/
export function resolveContentPosition(
contentPosition?: ImageContentPosition
): ImageContentPositionObject {
if (typeof contentPosition === 'string') {
const contentPositionStringMappings: Record<
ImageContentPositionString,
ImageContentPositionObject
> = {
center: { top: '50%', left: '50%' },
top: { top: 0, left: '50%' },
right: { top: '50%', right: 0 },
bottom: { bottom: 0, left: '50%' },
left: { top: '50%', left: 0 },
'top center': { top: 0, left: '50%' },
'top right': { top: 0, right: 0 },
'top left': { top: 0, left: 0 },
'right center': { top: '50%', right: 0 },
'right top': { top: 0, right: 0 },
'right bottom': { bottom: 0, right: 0 },
'bottom center': { bottom: 0, left: '50%' },
'bottom right': { bottom: 0, right: 0 },
'bottom left': { bottom: 0, left: 0 },
'left center': { top: '50%', left: 0 },
'left top': { top: 0, left: 0 },
'left bottom': { bottom: 0, left: 0 },
};
const contentPositionObject = contentPositionStringMappings[contentPosition];
if (!contentPositionObject) {
console.warn(`[expo-image]: Content position "${contentPosition}" is invalid`);
return contentPositionStringMappings.center;
}
return contentPositionObject;
}
return contentPosition ?? { top: '50%', left: '50%' };
}
/**
* If `transition` or `fadeDuration` is a number, it's resolved to a cross dissolve transition with the given duration.
* When `fadeDuration` is used, it logs an appropriate deprecation warning.
*/
export function resolveTransition(
transition?: ImageProps['transition'],
fadeDuration?: ImageProps['fadeDuration']
): ImageTransition | null {
if (typeof transition === 'number') {
return { duration: transition };
}
if (!transition && typeof fadeDuration === 'number') {
if (!loggedFadeDurationDeprecationWarning) {
console.warn('[expo-image]: Prop "fadeDuration" is deprecated, use "transition" instead');
loggedFadeDurationDeprecationWarning = true;
}
return { duration: fadeDuration };
}
return transition ?? null;
}
/**
* Checks whether the given value is an instance of the `SharedRef<'image'>` class.
*/
export function isImageRef(value: any): value is SharedRefType<'image'> {
return value instanceof SharedRef && value.nativeRefType === 'image';
}
+88
View File
@@ -0,0 +1,88 @@
import { PackagerAsset } from '@react-native/assets-registry/registry';
import { Platform } from 'expo-modules-core';
import { PixelRatio } from 'react-native';
export type ResolvedAssetSource = {
__packager_asset: boolean;
width?: number;
height?: number;
uri: string;
scale: number;
};
// Returns the Metro dev server-specific asset location.
function getScaledAssetPath(asset: PackagerAsset): string {
const scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get());
const scaleSuffix = scale === 1 ? '' : '@' + scale + 'x';
const type = !asset.type ? '' : `.${asset.type}`;
if (__DEV__) {
return asset.httpServerLocation + '/' + asset.name + scaleSuffix + type;
} else {
return asset.httpServerLocation.replace(/\.\.\//g, '_') + '/' + asset.name + scaleSuffix + type;
}
}
export default class AssetSourceResolver {
serverUrl: string;
// where the jsbundle is being run from
// NOTE(EvanBacon): Never defined on web.
jsbundleUrl?: string | null;
// the asset to resolve
asset: PackagerAsset;
constructor(
serverUrl: string | undefined | null,
jsbundleUrl: string | undefined | null,
asset: PackagerAsset
) {
this.serverUrl = serverUrl || 'https://expo.dev';
this.jsbundleUrl = null;
this.asset = asset;
}
// Always true for web runtimes
isLoadedFromServer(): boolean {
return true;
}
// Always false for web runtimes
isLoadedFromFileSystem(): boolean {
return false;
}
defaultAsset(): ResolvedAssetSource {
return this.assetServerURL();
}
/**
* @returns absolute remote URL for the hosted asset.
*/
assetServerURL(): ResolvedAssetSource {
const fromUrl = new URL(getScaledAssetPath(this.asset), this.serverUrl);
fromUrl.searchParams.set('platform', Platform.OS);
fromUrl.searchParams.set('hash', this.asset.hash);
return this.fromSource(
// Relative on web
fromUrl.toString().replace(fromUrl.origin, '')
);
}
fromSource(source: string): ResolvedAssetSource {
return {
__packager_asset: true,
width: this.asset.width ?? undefined,
height: this.asset.height ?? undefined,
uri: source,
scale: AssetSourceResolver.pickScale(this.asset.scales, PixelRatio.get()),
};
}
static pickScale(scales: number[], deviceScale: number): number {
for (let i = 0; i < scales.length; i++) {
if (scales[i] >= deviceScale) {
return scales[i];
}
}
return scales[scales.length - 1] || 1;
}
}
+104
View File
@@ -0,0 +1,104 @@
const digitCharacters = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'#',
'$',
'%',
'*',
'+',
',',
'-',
'.',
':',
';',
'=',
'?',
'@',
'[',
']',
'^',
'_',
'{',
'|',
'}',
'~',
];
export const decode83 = (str: string) => {
let value = 0;
for (let i = 0; i < str.length; i++) {
const c = str[i];
const digit = digitCharacters.indexOf(c);
value = value * 83 + digit;
}
return value;
};
export const encode83 = (n: number, length: number): string => {
let result = '';
for (let i = 1; i <= length; i++) {
const digit = (Math.floor(n) / Math.pow(83, length - i)) % 83;
result += digitCharacters[Math.floor(digit)];
}
return result;
};
+114
View File
@@ -0,0 +1,114 @@
import { decode83 } from './base83';
import { ValidationError } from './error';
import { sRGBToLinear, signPow, linearTosRGB } from './utils';
/**
* Returns an error message if invalid or undefined if valid
* @param blurhash
*/
const validateBlurhash = (blurhash: string) => {
if (!blurhash || blurhash.length < 6) {
throw new ValidationError('The blurhash string must be at least 6 characters');
}
const sizeFlag = decode83(blurhash[0]);
const numY = Math.floor(sizeFlag / 9) + 1;
const numX = (sizeFlag % 9) + 1;
if (blurhash.length !== 4 + 2 * numX * numY) {
throw new ValidationError(
`blurhash length mismatch: length is ${blurhash.length} but it should be ${
4 + 2 * numX * numY
}`
);
}
};
export const isBlurhashValid = (blurhash: string): { result: boolean; errorReason?: string } => {
try {
validateBlurhash(blurhash);
} catch (error: any) {
return { result: false, errorReason: error.message };
}
return { result: true };
};
const decodeDC = (value: number) => {
const intR = value >> 16;
const intG = (value >> 8) & 255;
const intB = value & 255;
return [sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)];
};
const decodeAC = (value: number, maximumValue: number) => {
const quantR = Math.floor(value / (19 * 19));
const quantG = Math.floor(value / 19) % 19;
const quantB = value % 19;
const rgb = [
signPow((quantR - 9) / 9, 2.0) * maximumValue,
signPow((quantG - 9) / 9, 2.0) * maximumValue,
signPow((quantB - 9) / 9, 2.0) * maximumValue,
];
return rgb;
};
const decode = (blurhash: string, width: number, height: number, punch?: number) => {
validateBlurhash(blurhash);
punch = (punch || 1) | 1;
const sizeFlag = decode83(blurhash[0]);
const numY = Math.floor(sizeFlag / 9) + 1;
const numX = (sizeFlag % 9) + 1;
const quantisedMaximumValue = decode83(blurhash[1]);
const maximumValue = (quantisedMaximumValue + 1) / 166;
const colors = new Array(numX * numY);
for (let i = 0; i < colors.length; i++) {
if (i === 0) {
const value = decode83(blurhash.substring(2, 6));
colors[i] = decodeDC(value);
} else {
const value = decode83(blurhash.substring(4 + i * 2, 6 + i * 2));
colors[i] = decodeAC(value, maximumValue * punch);
}
}
const bytesPerRow = width * 4;
const pixels = new Uint8ClampedArray(bytesPerRow * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0;
let g = 0;
let b = 0;
for (let j = 0; j < numY; j++) {
for (let i = 0; i < numX; i++) {
const basis = Math.cos((Math.PI * x * i) / width) * Math.cos((Math.PI * y * j) / height);
const color = colors[i + j * numX];
r += color[0] * basis;
g += color[1] * basis;
b += color[2] * basis;
}
}
const intR = linearTosRGB(r);
const intG = linearTosRGB(g);
const intB = linearTosRGB(b);
pixels[4 * x + 0 + y * bytesPerRow] = intR;
pixels[4 * x + 1 + y * bytesPerRow] = intG;
pixels[4 * x + 2 + y * bytesPerRow] = intB;
pixels[4 * x + 3 + y * bytesPerRow] = 255; // alpha
}
}
return pixels;
};
export default decode;
+7
View File
@@ -0,0 +1,7 @@
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
this.message = message;
}
}
+85
View File
@@ -0,0 +1,85 @@
// adapted from https://gist.github.com/ngbrown/d62eb518753378eb0a9bf02bb4723235
// modified from https://gist.github.com/WorldMaker/a3cbe0059acd827edee568198376b95a
// https://github.com/woltapp/react-blurhash/issues/3
import { useEffect, useState, useMemo } from 'react';
import decode from './decode';
import { isBlurhashString } from '../resolveSources';
const DEFAULT_SIZE = {
width: 32,
height: 32,
};
// We scale up the canvas to avoid an irritating visual glitch when animating in Chrome.
const scaleRatio = 10;
export function useBlurhash(
blurhash: { uri?: string; width?: number | null; height?: number | null } | undefined | null,
punch: number = 1
) {
punch = punch || 1;
const [uri, setUri] = useState<string | null>(null);
const isBlurhash = (blurhash?.uri && isBlurhashString(blurhash.uri)) ?? false;
useEffect(() => {
let isCanceled = false;
if (!blurhash || !blurhash.uri || !isBlurhash) {
return;
}
const strippedBlurhashString = blurhash.uri.replace(/blurhash:\//, '');
const pixels = decode(
strippedBlurhashString,
blurhash.width ?? DEFAULT_SIZE.width,
blurhash.height ?? DEFAULT_SIZE.height,
punch
);
const canvas = document.createElement('canvas');
const upscaledCanvas = document.createElement('canvas');
canvas.width = blurhash.width ?? DEFAULT_SIZE.width;
canvas.height = blurhash.height ?? DEFAULT_SIZE.height;
upscaledCanvas.width = (blurhash.width ?? DEFAULT_SIZE.width) * scaleRatio;
upscaledCanvas.height = (blurhash.height ?? DEFAULT_SIZE.height) * scaleRatio;
const context = canvas.getContext('2d');
if (!context) {
console.warn('Failed to decode blurhash');
return;
}
const imageData = context.createImageData(canvas.width, canvas.height);
imageData.data.set(pixels);
context.putImageData(imageData, 0, 0);
const upscaledContext = upscaledCanvas.getContext('2d');
if (!upscaledContext) {
console.warn('Failed to decode blurhash');
return;
}
upscaledContext.scale(scaleRatio, scaleRatio);
upscaledContext.drawImage(canvas, 0, 0);
upscaledCanvas.toBlob((blob) => {
if (!isCanceled) {
setUri((oldUrl) => {
if (oldUrl) {
URL.revokeObjectURL(oldUrl);
}
return blob ? URL.createObjectURL(blob) : oldUrl;
});
}
});
return function cleanupBlurhash() {
isCanceled = true;
setUri((oldUrl) => {
if (oldUrl) {
URL.revokeObjectURL(oldUrl);
}
return null;
});
};
}, [blurhash?.uri, blurhash?.height, blurhash?.width, punch, isBlurhash]);
const source = useMemo(() => (uri ? { uri } : null), [uri]);
return [source, isBlurhash] as const;
}
+21
View File
@@ -0,0 +1,21 @@
export const sRGBToLinear = (value: number) => {
const v = value / 255;
if (v <= 0.04045) {
return v / 12.92;
} else {
return Math.pow((v + 0.055) / 1.055, 2.4);
}
};
export const linearTosRGB = (value: number) => {
const v = Math.max(0, Math.min(1, value));
if (v <= 0.0031308) {
return Math.trunc(v * 12.92 * 255 + 0.5);
} else {
return Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
}
};
export const sign = (n: number) => (n < 0 ? -1 : 1);
export const signPow = (val: number, exp: number) => sign(val) * Math.pow(Math.abs(val), exp);
+3
View File
@@ -0,0 +1,3 @@
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';
export default resolveAssetSource;
+40
View File
@@ -0,0 +1,40 @@
import { getAssetByID } from '@react-native/assets-registry/registry';
import AssetSourceResolver, { ResolvedAssetSource } from './AssetSourceResolver.web';
let _customSourceTransformer: undefined | ((resolver: AssetSourceResolver) => ResolvedAssetSource);
export function setCustomSourceTransformer(
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource
): void {
_customSourceTransformer = transformer;
}
/**
* `source` is either a number (opaque type returned by require('./foo.png'))
* or an `ImageSource` like { uri: '<http location || file path>' }
*/
export default function resolveAssetSource(source: any): ResolvedAssetSource | undefined {
if (typeof source === 'object') {
return source;
}
const asset = getAssetByID(source);
if (!asset) {
return undefined;
}
const resolver = new AssetSourceResolver('https://expo.dev', null, asset);
if (_customSourceTransformer) {
return _customSourceTransformer(resolver);
}
return resolver.defaultAsset();
}
Object.defineProperty(resolveAssetSource, 'setCustomSourceTransformer', {
get() {
return setCustomSourceTransformer;
},
});
export const { pickScale } = AssetSourceResolver;
+36
View File
@@ -0,0 +1,36 @@
import { ImageSource } from '../Image.types';
type ImageHashType = 'blurhash' | 'thumbhash';
function hashToUri(type: ImageHashType, hash: string): string {
const encodedBlurhash = encodeURI(hash).replace(/#/g, '%23').replace(/\?/g, '%3F');
return `${type}:/${encodedBlurhash}`;
}
/**
* Converts a blurhash string (`blurhash:/<hash>/<width>/<height>` or <hash>/<width>/<height>) into an `ImageSource`.
*
* @return An ImageSource representing the provided blurhash.
* */
export function resolveBlurhashString(str: string): ImageSource {
const [blurhash, width, height] = str.replace(/^blurhash:\//, '').split('/');
return {
uri: hashToUri('blurhash', blurhash),
width: parseInt(width, 10) || 16,
height: parseInt(height, 10) || 16,
};
}
/**
* Converts a thumbhash string (`thumbhash:/<hash>` or `<hash>`) into an `ImageSource`.
*
* @return An ImageSource representing the provided thumbhash.
* */
export function resolveThumbhashString(str: string): ImageSource {
// ThumbHash may contain slashes that could break the url when the slash is at the beginning.
// We replace slashes with backslashes to make sure we don't break the url's path.
const thumbhash = str.replace(/^thumbhash:\//, '').replace(/\//g, '\\');
return {
uri: hashToUri('thumbhash', thumbhash),
};
}
+31
View File
@@ -0,0 +1,31 @@
import { ImageSource } from '../Image.types';
/**
* Converts a string in blurhash format (`blurhash:/<hash>/<width>/<height>`
* or <hash>/<width>/<height>) into an `ImageSource`.
*
* @return An ImageSource representing the provided blurhash.
* */
export function resolveBlurhashString(str: string): ImageSource {
const [hash, width, height] = str.replace(/^blurhash:\//, '').split('/');
return {
uri: 'blurhash:/' + hash,
width: parseInt(width, 10) || 16,
height: parseInt(height, 10) || 16,
};
}
/**
* Converts a string in thumbhash format (`thumbhash:/<hash>` or `<hash>`)
* into an `ImageSource`.
* Note: Unlike the `resolveBlurhashString` the `thumbhash:/` scheme has to be present,
* as the scheme has to be explicitly stated to be interpreted a `thumbhash` source.
*
* @return An ImageSource representing the provided thumbhash.
* */
export function resolveThumbhashString(str: string): ImageSource {
const hash = str.replace(/^thumbhash:\//, '');
return {
uri: 'thumbhash:/' + hash,
};
}
+58
View File
@@ -0,0 +1,58 @@
import { Platform } from 'expo-modules-core';
import resolveAssetSource from './resolveAssetSource';
import { resolveBlurhashString, resolveThumbhashString } from './resolveHashString';
import { ImageNativeProps, ImageProps, ImageSource } from '../Image.types';
import { isImageRef } from '../utils';
export function isBlurhashString(str: string): boolean {
return /^(blurhash:\/)+[\w#$%*+,\-.:;=?@[\]^_{}|~]+(\/[\d.]+)*$/.test(str);
}
// Base64 strings will be recognized as blurhash by default (to keep compatibility),
// interpret as thumbhash only if correct uri scheme is provided
export function isThumbhashString(str: string): boolean {
return str.startsWith('thumbhash:/');
}
export function resolveSource(source?: ImageSource | string | number | null): ImageSource | null {
if (typeof source === 'string') {
if (isBlurhashString(source)) {
return resolveBlurhashString(source);
} else if (isThumbhashString(source)) {
return resolveThumbhashString(source);
}
return { uri: source };
}
if (typeof source === 'number') {
return resolveAssetSource(source);
}
if (typeof source === 'object' && (source?.blurhash || source?.thumbhash)) {
const { blurhash, thumbhash, ...restSource } = source;
const resolved = thumbhash
? resolveThumbhashString(thumbhash)
: resolveBlurhashString(blurhash as string);
return {
...resolved,
...restSource,
};
}
return source ?? null;
}
/**
* Resolves provided `source` prop to an array of objects expected by the native implementation.
*/
export function resolveSources(sources?: ImageProps['source']): ImageNativeProps['source'] {
if (Array.isArray(sources)) {
return sources.map(resolveSource).filter(Boolean) as ImageSource[];
}
if (isImageRef(sources)) {
if (Platform.OS === 'web') {
return sources;
}
// @ts-expect-error
return sources.__expo_shared_object_id__;
}
return [resolveSource(sources)].filter(Boolean) as ImageSource[];
}
+387
View File
@@ -0,0 +1,387 @@
// Code copied and slightly modified from https://github.com/evanw/thumbhash
/**
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
*
* @param w The width of the input image. Must be ≤100px.
* @param h The height of the input image. Must be ≤100px.
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
* @returns The ThumbHash as a Uint8Array.
*/
export function rgbaToThumbHash(w: number, h: number, rgba: Uint8Array) {
// Encoding an image larger than 100x100 is slow with no benefit
if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`);
const { PI, round, max, cos, abs } = Math;
// Determine the average color
let avg_r = 0,
avg_g = 0,
avg_b = 0,
avg_a = 0;
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
const alpha = rgba[j + 3] / 255;
avg_r += (alpha / 255) * rgba[j];
avg_g += (alpha / 255) * rgba[j + 1];
avg_b += (alpha / 255) * rgba[j + 2];
avg_a += alpha;
}
if (avg_a) {
avg_r /= avg_a;
avg_g /= avg_a;
avg_b /= avg_a;
}
const hasAlpha = avg_a < w * h;
const l_limit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's alpha
const lx = max(1, round((l_limit * w) / max(w, h)));
const ly = max(1, round((l_limit * h) / max(w, h)));
const l: number[] = []; // luminance
const p: number[] = []; // yellow - blue
const q: number[] = []; // red - green
const a: number[] = []; // alpha
// Convert the image from RGBA to LPQA (composite atop the average color)
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
const alpha = rgba[j + 3] / 255;
const r = avg_r * (1 - alpha) + (alpha / 255) * rgba[j];
const g = avg_g * (1 - alpha) + (alpha / 255) * rgba[j + 1];
const b = avg_b * (1 - alpha) + (alpha / 255) * rgba[j + 2];
l[i] = (r + g + b) / 3;
p[i] = (r + g) / 2 - b;
q[i] = r - g;
a[i] = alpha;
}
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
const encodeChannel = (channel: number[], nx: number, ny: number) => {
let dc = 0;
const ac: number[] = [];
let scale = 0;
const fx: number[] = [];
for (let cy = 0; cy < ny; cy++) {
for (let cx = 0; cx * ny < nx * (ny - cy); cx++) {
let f = 0;
for (let x = 0; x < w; x++) fx[x] = cos((PI / w) * cx * (x + 0.5));
for (let y = 0; y < h; y++)
for (let x = 0, fy = cos((PI / h) * cy * (y + 0.5)); x < w; x++)
f += channel[x + y * w] * fx[x] * fy;
f /= w * h;
if (cx || cy) {
ac.push(f);
scale = max(scale, abs(f));
} else {
dc = f;
}
}
}
if (scale) for (let i = 0; i < ac.length; i++) ac[i] = 0.5 + (0.5 / scale) * ac[i];
return [dc, ac, scale];
};
const [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly));
const [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3);
const [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3);
const [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [];
// Write the constants
const isLandscape = w > h;
const header24 =
round(63 * (l_dc as number)) |
(round(31.5 + 31.5 * (p_dc as number)) << 6) |
(round(31.5 + 31.5 * (q_dc as number)) << 12) |
(round(31 * (l_scale as number)) << 18) |
((hasAlpha ? 1 : 0) << 23);
const header16 =
(isLandscape ? ly : lx) |
(round(63 * (p_scale as number)) << 3) |
(round(63 * (q_scale as number)) << 9) |
((isLandscape ? 1 : 0) << 15);
const hash = [
header24 & 255,
(header24 >> 8) & 255,
header24 >> 16,
header16 & 255,
header16 >> 8,
];
const ac_start = hasAlpha ? 6 : 5;
let ac_index = 0;
if (hasAlpha) hash.push(round(15 * (a_dc as number)) | (round(15 * (a_scale as number)) << 4));
// Write the varying factors
for (const ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac])
for (const f of ac as number[])
hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2);
return new Uint8Array(hash);
}
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @returns The width, height, and pixels of the rendered placeholder image.
*/
export function thumbHashToRGBA(hash: Uint8Array) {
const { PI, min, max, cos, round } = Math;
// Read the constants
const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16);
const header16 = hash[3] | (hash[4] << 8);
const l_dc = (header24 & 63) / 63;
const p_dc = ((header24 >> 6) & 63) / 31.5 - 1;
const q_dc = ((header24 >> 12) & 63) / 31.5 - 1;
const l_scale = ((header24 >> 18) & 31) / 31;
const hasAlpha = header24 >> 23;
const p_scale = ((header16 >> 3) & 63) / 63;
const q_scale = ((header16 >> 9) & 63) / 63;
const isLandscape = header16 >> 15;
const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7);
const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1;
const a_scale = (hash[5] >> 4) / 15;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
const ac_start = hasAlpha ? 6 : 5;
let ac_index = 0;
const decodeChannel = (nx: number, ny: number, scale: number) => {
const ac: number[] = [];
for (let cy = 0; cy < ny; cy++)
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
ac.push(
(((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale
);
return ac;
};
const l_ac = decodeChannel(lx, ly, l_scale);
const p_ac = decodeChannel(3, 3, p_scale * 1.25);
const q_ac = decodeChannel(3, 3, q_scale * 1.25);
const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null;
// Decode using the DCT into RGB
const ratio = thumbHashToApproximateAspectRatio(hash);
const w = round(ratio > 1 ? 32 : 32 * ratio);
const h = round(ratio > 1 ? 32 / ratio : 32);
const rgba = new Uint8Array(w * h * 4),
fx: number[] = [],
fy: number[] = [];
for (let y = 0, i = 0; y < h; y++) {
for (let x = 0; x < w; x++, i += 4) {
let l = l_dc,
p = p_dc,
q = q_dc,
a = a_dc;
// Precompute the coefficients
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
fx[cx] = cos((PI / w) * (x + 0.5) * cx);
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
fy[cy] = cos((PI / h) * (y + 0.5) * cy);
// Decode L
for (let cy = 0, j = 0; cy < ly; cy++)
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
l += l_ac[j] * fx[cx] * fy2;
// Decode P and Q
for (let cy = 0, j = 0; cy < 3; cy++) {
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
const f = fx[cx] * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
}
}
// Decode A
if (hasAlpha)
for (let cy = 0, j = 0; cy < 5; cy++)
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++)
a += a_ac![j] * fx[cx] * fy2;
// Convert to RGB
const b = l - (2 / 3) * p;
const r = (3 * l - b + q) / 2;
const g = r - q;
rgba[i] = max(0, 255 * min(1, r));
rgba[i + 1] = max(0, 255 * min(1, g));
rgba[i + 2] = max(0, 255 * min(1, b));
rgba[i + 3] = max(0, 255 * min(1, a));
}
}
return { w, h, rgba };
}
/**
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @returns The RGBA values for the average color. Each value ranges from 0 to 1.
*/
export function thumbHashToAverageRGBA(hash: Uint8Array) {
const { min, max } = Math;
const header = hash[0] | (hash[1] << 8) | (hash[2] << 16);
const l = (header & 63) / 63;
const p = ((header >> 6) & 63) / 31.5 - 1;
const q = ((header >> 12) & 63) / 31.5 - 1;
const hasAlpha = header >> 23;
const a = hasAlpha ? (hash[5] & 15) / 15 : 1;
const b = l - (2 / 3) * p;
const r = (3 * l - b + q) / 2;
const g = r - q;
return {
r: max(0, min(1, r)),
g: max(0, min(1, g)),
b: max(0, min(1, b)),
a,
};
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @returns The approximate aspect ratio (i.e. width / height).
*/
export function thumbHashToApproximateAspectRatio(hash: Uint8Array) {
const header = hash[3];
const hasAlpha = hash[2] & 0x80;
const isLandscape = hash[4] & 0x80;
const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7;
const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
return lx / ly;
}
/**
* Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
* A. This is optimized for speed and simplicity and does not optimize for size
* at all. This doesn't do any compression (all values are stored uncompressed).
*
* @param w The width of the input image. Must be ≤100px.
* @param h The height of the input image. Must be ≤100px.
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
* @returns A data URL containing a PNG for the input image.
*/
export function rgbaToDataURL(w: number, h: number, rgba: Uint8Array) {
const row = w * 4 + 1;
const idat = 6 + h * (5 + row);
const bytes = [
137,
80,
78,
71,
13,
10,
26,
10,
0,
0,
0,
13,
73,
72,
68,
82,
0,
0,
w >> 8,
w & 255,
0,
0,
h >> 8,
h & 255,
8,
6,
0,
0,
0,
0,
0,
0,
0,
idat >>> 24,
(idat >> 16) & 255,
(idat >> 8) & 255,
idat & 255,
73,
68,
65,
84,
120,
1,
];
const table = [
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948, -306674912,
-267414716, -690576408, -882789492, -1687895376, -2032938284, -1609899400, -1111625188,
];
let a = 1,
b = 0;
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0);
for (b = (b + a) % 65521; i < end; i++) {
const u = rgba[i] & 255;
bytes.push(u);
a = (a + u) % 65521;
b = (b + a) % 65521;
}
}
bytes.push(
b >> 8,
b & 255,
a >> 8,
a & 255,
0,
0,
0,
0,
0,
0,
0,
0,
73,
69,
78,
68,
174,
66,
96,
130
);
for (let [start, end] of [
[12, 29],
[37, 41 + idat],
]) {
let c = ~0;
for (let i = start; i < end; i++) {
c ^= bytes[i];
c = (c >>> 4) ^ table[c & 15];
c = (c >>> 4) ^ table[c & 15];
}
c = ~c;
bytes[end++] = c >>> 24;
bytes[end++] = (c >> 16) & 255;
bytes[end++] = (c >> 8) & 255;
bytes[end++] = c & 255;
}
return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes));
}
/**
* Decodes a ThumbHash to a PNG data URL. This is a convenience function that
* just calls "thumbHashToRGBA" followed by "rgbaToDataURL".
*
* @param hash The bytes of the ThumbHash.
* @returns A data URL containing a PNG for the rendered ThumbHash.
*/
export function thumbHashToDataURL(hash: Uint8Array): string {
const image = thumbHashToRGBA(hash);
return rgbaToDataURL(image.w, image.h, image.rgba);
}
/**
* Convenience function added to the original thumbhash code, allows generating a thumbhash image directly from
* thumbhash string.
* @param thumbhash string from which thumbhashDataURL should be generated
* @returns A data URL containing a PNG for the rendered ThumbHash
*/
export function thumbHashStringToDataURL(thumbhash: string): string {
const hash = Uint8Array.from(atob(thumbhash), (c) => c.charCodeAt(0));
return thumbHashToDataURL(hash);
}
+231
View File
@@ -0,0 +1,231 @@
import React from 'react';
import { ImageTransition } from '../Image.types';
type Callbacks = {
onReady?: (() => void) | null;
onAnimationFinished?: (() => void) | null;
onMount?: (() => void) | null;
onError?: (() => void) | null;
};
export type AnimationManagerNode = [
key: string,
renderFunction: (
renderProps: NonNullable<Callbacks>
) => (className: string, style: React.CSSProperties) => React.ReactElement,
];
const SUPPORTED_ANIMATIONS: ImageTransition['effect'][] = [
'cross-dissolve',
'flip-from-left',
'flip-from-right',
'flip-from-top',
'flip-from-bottom',
];
type NodeStatus = 'mounted' | 'in' | 'active' | 'out' | 'errored';
function useAnimationManagerNode(node: AnimationManagerNode | null, initialStatus?: NodeStatus) {
const newNode = React.useMemo(() => {
if (!node) {
return null;
}
const [animationKey, renderFunction] = node;
// key, ReactElement, ref, callbacks
return {
animationKey,
persistedElement: renderFunction,
status: (initialStatus || 'mounted') as NodeStatus,
};
}, [node?.[0]]);
return newNode;
}
function validateTimingFunctionForAnimation(
animationClass: ImageTransition['effect'],
timingFunction: ImageTransition['timing']
) {
if (animationClass?.includes('flip')) {
if (timingFunction?.includes('ease')) {
return 'ease-in-out';
}
return 'linear';
}
return timingFunction || null;
}
function validateAnimationClass(effect: ImageTransition['effect']) {
if (SUPPORTED_ANIMATIONS.includes(effect)) {
return effect;
}
return 'cross-dissolve';
}
export function getAnimatorFromTransition(transition: ImageTransition | null | undefined) {
if (!transition?.duration) {
return null;
}
const animationClass = validateAnimationClass(transition.effect);
if (!animationClass) {
return {
startingClass: '',
animateInClass: '',
animateOutClass: '',
containerClass: '',
timingFunction: 'linear',
animationClass: '',
duration: 0,
};
}
const timingFunction = validateTimingFunctionForAnimation(animationClass, transition.timing);
const timingClass = `image-timing-${timingFunction}`;
return {
startingClass: `${animationClass}-start`,
animateInClass: [animationClass, 'transitioning', `${animationClass}-active`, timingClass].join(
' '
),
animateOutClass: [animationClass, `${animationClass}-end`, timingClass].join(' '),
containerClass: `${animationClass}-container`,
timingFunction,
animationClass,
duration: transition?.duration || 0,
};
}
type MountedAnimationNode = {
animationKey: string;
persistedElement: (
renderProps: Callbacks
) => (className: string, style: React.CSSProperties) => React.ReactElement;
status: NodeStatus;
};
export default function AnimationManager({
children: renderFunction,
initial,
transition,
recyclingKey,
}: {
children: AnimationManagerNode;
initial: AnimationManagerNode | null;
transition: ImageTransition | null | undefined;
recyclingKey?: string | null | undefined;
}) {
const animation = getAnimatorFromTransition(transition);
const initialNode = useAnimationManagerNode(initial, 'active');
const [nodes, setNodes] = React.useState<MountedAnimationNode[]>(
initialNode ? [initialNode] : []
);
const [prevRecyclingKey, setPrevRecyclingKey] = React.useState<string>(recyclingKey ?? '');
if (prevRecyclingKey !== (recyclingKey ?? '')) {
setPrevRecyclingKey(recyclingKey ?? '');
setNodes(initialNode ? [initialNode] : []);
}
const removeAllNodesOfKeyExceptShowing = (key?: string) => {
setNodes((n) =>
n.filter(
(node) =>
(key ? node.animationKey !== key : false) ||
node.status === 'in' ||
node.status === 'active'
)
);
};
const newNode = useAnimationManagerNode(renderFunction);
React.useEffect(() => {
setNodes((n) => {
if (!newNode) {
return n;
}
const existingNodeIndex = n.findIndex((node) => node.animationKey === newNode.animationKey);
if (existingNodeIndex >= 0) {
if (animation) {
return n.map((n2) =>
n2.animationKey === newNode.animationKey
? { ...newNode, status: 'in' }
: { ...n2, status: 'out' }
);
} else {
return [{ ...newNode, status: 'in' }];
}
}
return [...n, newNode];
});
}, [newNode]);
function wrapNodeWithCallbacks(node: MountedAnimationNode) {
if (renderFunction[0] === node.animationKey) {
return renderFunction[1]({
onReady: () => {
if (animation) {
setNodes((nodes) =>
nodes.map((n) => (n === newNode ? { ...n, status: 'in' } : { ...n, status: 'out' }))
);
} else {
setNodes([{ ...node, status: 'in' }]);
}
},
onAnimationFinished: () => {
setNodes([{ ...node, status: 'in' }]);
},
onError: () => {
setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n)));
},
});
}
if (initial?.[0] === node.animationKey) {
return initial[1]({
onAnimationFinished: () => {
if (node.status === 'out') {
removeAllNodesOfKeyExceptShowing(node.animationKey);
}
},
onError: () => {
setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n)));
},
});
}
return node.persistedElement({
onAnimationFinished: () => {
removeAllNodesOfKeyExceptShowing(node.animationKey);
},
});
}
const styles = {
transitionDuration: `${animation?.duration || 0}ms`,
transitionTimingFunction: animation?.timingFunction || 'linear',
};
const classes = {
in: animation?.animateInClass,
out: animation?.animateOutClass,
mounted: animation?.startingClass,
};
return (
<>
{[...nodes]
.filter((n) => n.status !== 'errored')
.map((n) => {
const status = n.status as keyof typeof classes & NodeStatus;
// TODO(@kitten): This creates impossible states!
// Ensure that the above type is either exhaustively reflected in this `map` so `className` sheds `undefined`,
// or retype the `MountedAnimationNode` function to accept `className: string | undefined`
const className = classes[status]!;
return (
<div className={animation?.containerClass} key={n.animationKey}>
{wrapNodeWithCallbacks(n)(className, styles)}
</div>
);
})}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React from 'react';
import { StyleSheet } from 'react-native';
export function getTintColorStyle(tintId: string, tintColor?: string | null) {
if (!tintColor) {
return {};
}
return {
filter: `url(#expo-image-tint-${tintId})`,
};
}
type TintColorFilterProps = { id: string; tintColor?: string | null };
export default function TintColorFilter({ id, tintColor }: TintColorFilterProps) {
if (!tintColor) {
return null;
}
return (
<svg style={styles.svg}>
<defs>
<filter id={`expo-image-tint-${id}`}>
<feFlood floodColor={tintColor} />
<feComposite in2="SourceAlpha" operator="atop" />
</filter>
</defs>
</svg>
);
}
const styles = StyleSheet.create({
svg: {
width: 0,
height: 0,
},
});
+24
View File
@@ -0,0 +1,24 @@
import { SharedRef } from 'expo';
import { ImageRef } from '../Image.types';
export default class ImageRefWeb extends SharedRef<'image'> implements ImageRef {
override nativeRefType = 'image';
uri: string | null = null;
width: number = 0;
height: number = 0;
mediaType: string | null = null;
scale: number = 1;
isAnimated: boolean = false;
static init(uri: string, width: number, height: number, mediaType: string | null): ImageRefWeb {
return Object.assign(new ImageRefWeb(), {
uri,
width,
height,
mediaType,
isAnimated: mediaType === 'image/gif',
});
}
}
+94
View File
@@ -0,0 +1,94 @@
import React, { useEffect, Ref, useId } from 'react';
import ColorTintFilter, { getTintColorStyle } from './ColorTintFilter';
import { ImageWrapperProps } from './ImageWrapper.types';
import { getImageWrapperEventHandler } from './getImageWrapperEventHandler';
import { useHeaders, useImageHashes } from './hooks';
import { absoluteFilledPosition, getObjectPositionFromContentPositionObject } from './positioning';
import { SrcSetSource } from './useSourceSelection';
import { ImageNativeProps, ImageSource } from '../Image.types';
function getFetchPriorityFromImagePriority(priority: ImageNativeProps['priority'] = 'normal') {
return priority && ['low', 'high'].includes(priority) ? priority : 'auto';
}
function getImgPropsFromSource(source: ImageSource | SrcSetSource | null | undefined) {
if (source && 'srcset' in source) {
return {
srcSet: source.srcset,
sizes: source.sizes,
};
}
return {};
}
const ImageWrapper = React.forwardRef(
(
{
source,
events,
contentPosition,
hashPlaceholderContentPosition,
priority,
style,
hashPlaceholderStyle,
tintColor,
className,
accessibilityLabel,
cachePolicy,
...props
}: ImageWrapperProps,
ref: Ref<HTMLImageElement>
) => {
useEffect(() => {
events?.onMount?.forEach((e) => e?.());
}, []);
// Use a unique ID for the SVG filter so that multiple <Image> can be used
// on the same page with different tint colors without conflicts.
const tintId = useId()
// Make it safe for use as an SVG ID. SVG IDs are most strict than HTML
// IDs. They must be compliant with https://www.w3.org/TR/xml/#NT-Name.
// React 19 changed useId() to include « and ». These must be removed or
// the SVG filter will not work (e.g. in Safari which enforces the spec).
.replace(/[«»]/g, '_');
// Thumbhash uri always has to start with 'thumbhash:/'
const { resolvedSource, isImageHash } = useImageHashes(source);
const objectPosition = getObjectPositionFromContentPositionObject(
isImageHash ? hashPlaceholderContentPosition : contentPosition
);
const sourceWithHeaders = useHeaders(resolvedSource, cachePolicy, events?.onError);
if (!sourceWithHeaders) {
return null;
}
return (
<>
<ColorTintFilter id={tintId} tintColor={tintColor} />
<img
ref={ref}
alt={accessibilityLabel}
className={className}
src={sourceWithHeaders?.uri || undefined}
key={source?.uri}
style={{
objectPosition,
...absoluteFilledPosition,
...getTintColorStyle(tintId, tintColor),
...style,
...(isImageHash ? hashPlaceholderStyle : {}),
}}
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
{...getImgPropsFromSource(source)}
{...props}
/>
</>
);
}
);
export default ImageWrapper;
+38
View File
@@ -0,0 +1,38 @@
import { CSSProperties, SyntheticEvent } from 'react';
import { SrcSetSource } from './useSourceSelection';
import { ImageContentPositionObject, ImageProps, ImageSource } from '../Image.types';
export type OnErrorEvent =
| (({ source }: { source: ImageSource | null }) => void)
| undefined
| null;
export type OnLoadEvent =
| ((event: SyntheticEvent<HTMLImageElement, Event>) => void)
| undefined
| null;
export type OnTransitionEndEvent = (() => void) | undefined | null;
export type OnMountEvent = (() => void) | undefined | null;
export type OnDisplayEvent = (() => void) | undefined | null;
export type ImageWrapperEvents = {
onLoad?: OnLoadEvent[];
onError?: OnErrorEvent[];
onTransitionEnd?: OnTransitionEndEvent[];
onMount?: OnMountEvent[];
onDisplay?: OnDisplayEvent[];
};
export type ImageWrapperProps = {
source?: ImageSource | SrcSetSource | null;
events?: ImageWrapperEvents;
contentPosition?: ImageContentPositionObject;
hashPlaceholderContentPosition?: ImageContentPositionObject;
priority?: string | null;
style: CSSProperties;
tintColor?: string | null;
hashPlaceholderStyle?: CSSProperties;
className?: string;
accessibilityLabel?: string;
cachePolicy?: ImageProps['cachePolicy'];
};
+31
View File
@@ -0,0 +1,31 @@
import type { SyntheticEvent } from 'react';
import type { ImageWrapperEvents } from './ImageWrapper.types';
import type { ImageSource } from '../Image.types';
import { isBlurhashString } from '../utils/resolveSources';
export function getImageWrapperEventHandler(
events: ImageWrapperEvents | undefined,
source: ImageSource
) {
return {
onLoad: (event: SyntheticEvent<HTMLImageElement, Event>) => {
events?.onLoad?.forEach((e) => e?.(event));
if (typeof window !== 'undefined') {
// On Web there is no way to detect when the image gets displayed, but we can assume it happens on the repaint right after the image is successfully loaded.
window.requestAnimationFrame(() => {
events?.onDisplay?.forEach((e) => e?.());
});
}
},
onTransitionEnd: () => events?.onTransitionEnd?.forEach((e) => e?.()),
onError: () => {
// A temporary workaround for blurhash blobs throwing opaque errors when used in an img tag.
if (source?.uri && isBlurhashString(source?.uri)) {
return;
}
events?.onError?.forEach((e) => e?.({ source: source || null }));
},
};
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useState } from 'react';
import { OnErrorEvent } from './ImageWrapper.types';
import { ImageNativeProps, ImageSource } from '../Image.types';
import { useBlurhash } from '../utils/blurhash/useBlurhash';
import { isThumbhashString } from '../utils/resolveSources';
import { thumbHashStringToDataURL } from '../utils/thumbhash/thumbhash';
export function useThumbhash(source: ImageSource | null | undefined) {
const isThumbhash = isThumbhashString(source?.uri || '');
const strippedThumbhashString = source?.uri?.replace(/thumbhash:\//, '') ?? '';
const thumbhashSource = useMemo(
() => (isThumbhash ? { uri: thumbHashStringToDataURL(strippedThumbhashString) } : null),
[strippedThumbhashString, isThumbhash]
);
return [thumbhashSource, isThumbhash] as const;
}
export function useImageHashes(source: ImageSource | null | undefined) {
const [thumbhash, isThumbhashString] = useThumbhash(source);
const [blurhash, isBlurhashString] = useBlurhash(source);
return useMemo(() => {
if (!isThumbhashString && !isBlurhashString) {
return { resolvedSource: source, isImageHash: false };
}
if (!blurhash && !thumbhash) {
return { resolvedSource: null, isImageHash: true };
}
return {
resolvedSource: blurhash ?? thumbhash,
isImageHash: true,
};
}, [blurhash, thumbhash, isThumbhashString, isBlurhashString, source]);
}
export function useHeaders(
source: ImageSource | null | undefined,
cachePolicy: ImageNativeProps['cachePolicy'],
onError?: OnErrorEvent[]
): ImageSource | null | undefined {
const [objectURL, setObjectURL] = useState<string | null>(null);
useEffect(() => {
(async () => {
if (!source?.headers || !source.uri) {
return;
}
try {
const result = await fetch(source.uri, {
headers: source.headers,
cache: cachePolicy === 'none' ? 'no-cache' : 'default',
redirect: 'follow',
});
if (!result.ok) {
throw new Error(`Failed to fetch image: ${result.status} ${result.statusText}`);
}
const blob = await result.blob();
setObjectURL((prevObjURL) => {
if (prevObjURL) {
URL.revokeObjectURL(prevObjURL);
}
return URL.createObjectURL(blob);
});
} catch {
onError?.forEach((e) => e?.({ source }));
}
})();
}, [source]);
if (!source?.headers) {
return source;
}
if (!objectURL) {
// Avoid fetching a URL without headers if we have headers
return null;
}
return { ...source, uri: objectURL };
}
+136
View File
@@ -0,0 +1,136 @@
const style = `
[data-expoimage] .cross-dissolve {
transition-property: opacity;
animation-fill-mode: forwards;
}
[data-expoimage] .cross-dissolve-start:not(.transitioning) {
opacity: 0;
}
[data-expoimage] .cross-dissolve-active {
opacity: 1;
}
[data-expoimage] .cross-dissolve-end {
opacity: 0;
}
[data-expoimage] .flip-from-left {
transition-property: transform, opacity;
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
transform-origin: center;
}
[data-expoimage] .flip-from-left-container {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
perspective: 1000px;
}
[data-expoimage] .flip-from-left-start:not(.transitioning) {
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(-180deg);
opacity: 0;
}
[data-expoimage] .flip-from-left-active {
transform: translateZ(0px) rotateY(0) ;
opacity:1;
}
[data-expoimage] .flip-from-left-end {
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(180deg);
opacity: 0;
}
[data-expoimage] .flip-from-right {
transition-property: transform, opacity;
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
transform-origin: center;
}
[data-expoimage] .flip-from-right-container {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
perspective: 1000px;
}
[data-expoimage] .flip-from-right-start:not(.transitioning) {
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(180deg);
opacity: 0;
}
[data-expoimage] .flip-from-right-active {
transform: translateZ(0px) rotateY(0) ;
opacity:1;
}
[data-expoimage] .flip-from-right-end {
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(-180deg);
opacity: 0;
}
[data-expoimage] .flip-from-top {
transition-property: transform, opacity;
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
transform-origin: center;
}
[data-expoimage] .flip-from-top-container {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
perspective: 1000px;
}
[data-expoimage] .flip-from-top-start:not(.transitioning) {
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.5)) rotateX(180deg);
opacity: 0;
}
[data-expoimage] .flip-from-top-active {
transform: translateZ(0px) rotateX(0) ;
opacity:1;
}
[data-expoimage] .flip-from-top-end {
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.5)) rotateX(-180deg);
opacity: 0;
}
[data-expoimage] .flip-from-bottom {
transition-property: transform, opacity;
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
transform-origin: center;
}
[data-expoimage] .flip-from-bottom-container {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
perspective: 1000px;
}
[data-expoimage] .flip-from-bottom-start:not(.transitioning) {
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.25)) rotateX(-180deg);
opacity: 0;
}
[data-expoimage] .flip-from-bottom-active {
transform: translateZ(0px) rotateX(0) ;
opacity:1;
}
[data-expoimage] .flip-from-bottom-end {
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.25)) rotateX(180deg);
opacity: 0;
}
[data-expoimage] .image-timing-linear {
--expo-image-timing: linear;
}
[data-expoimage] .image-timing-ease-in {
--expo-image-timing: ease-in;
}
[data-expoimage] .image-timing-ease-out {
--expo-image-timing: ease-out;
}
[data-expoimage] .image-timing-ease-in-out {
--expo-image-timing: ease-in-out;
}
`;
export default function loadStyle() {
if (typeof window !== 'undefined') {
const styleTag = document.createElement('style');
styleTag.innerHTML = style;
styleTag.id = 'expo-image-styles';
document.head.appendChild(styleTag);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { ImageContentPositionObject, ImageContentPositionValue } from '../Image.types';
export function ensureValueIsWebUnits(value: string | number) {
const trimmedValue = String(value).trim();
if (trimmedValue.endsWith('%')) {
return trimmedValue;
}
return `${trimmedValue}px`;
}
type KeysOfUnion<T> = T extends T ? keyof T : never;
export const absoluteFilledPosition = {
width: '100%',
height: '100%',
position: 'absolute',
left: 0,
top: 0,
} as const;
export function getObjectPositionFromContentPositionObject(
contentPosition?: ImageContentPositionObject
): string {
const resolvedPosition = { ...contentPosition } as Record<
KeysOfUnion<ImageContentPositionObject>,
ImageContentPositionValue
>;
if (!resolvedPosition) {
return '50% 50%';
}
if (resolvedPosition.top == null && resolvedPosition.bottom == null) {
resolvedPosition.top = '50%';
}
if (resolvedPosition.left == null && resolvedPosition.right == null) {
resolvedPosition.left = '50%';
}
return (
(['top', 'bottom', 'left', 'right'] as const)
.map((key) => {
if (key in resolvedPosition) {
return `${key} ${ensureValueIsWebUnits(resolvedPosition[key])}`;
}
return '';
})
.join(' ') || '50% 50%'
);
}
+134
View File
@@ -0,0 +1,134 @@
import type { SharedRefType } from 'expo';
import React, { useState } from 'react';
import { ImageProps, ImageSource } from '../Image.types';
import { isImageRef } from '../utils';
import { isBlurhashString, isThumbhashString } from '../utils/resolveSources';
function findBestSourceForSize(
sources: ImageSource[] | undefined,
size: DOMRect | null
): ImageSource | null {
if (sources?.length === 1) {
return sources[0];
}
return (
[...(sources || [])]
// look for the smallest image that's still larger then a container
?.map((source) => {
if (!size) {
return { source, penalty: 0, covers: false };
}
const { width, height } =
typeof source === 'object' ? source : { width: null, height: null };
if (width == null || height == null) {
return { source, penalty: 0, covers: false };
}
if (width < size.width || height < size.height) {
return {
source,
penalty: Math.max(size.width - width, size.height - height),
covers: false,
};
}
return { source, penalty: (width - size.width) * (height - size.height), covers: true };
})
.sort((a, b) => a.penalty - b.penalty)
.sort((a, b) => Number(b.covers) - Number(a.covers))[0]?.source ?? null
);
}
export interface SrcSetSource extends ImageSource {
srcset: string;
sizes: string;
// used as key and a fallback in case srcset is not supported
uri: string;
type: 'srcset';
}
function getCSSMediaQueryForSource(source: ImageSource) {
return `(max-width: ${source.webMaxViewportWidth ?? source.width}px) ${source.width}px`;
}
function selectSource(
sources: ImageSource[] | undefined,
size: DOMRect | null,
responsivePolicy: ImageProps['responsivePolicy']
): ImageSource | SrcSetSource | null {
if (sources == null || sources.length === 0) {
return null;
}
if (sources.length === 1) {
return sources[0];
}
if (responsivePolicy !== 'static') {
return findBestSourceForSize(sources, size);
}
const staticSupportedSources = sources
.filter(
(s) => s.uri && s.width != null && !isBlurhashString(s.uri) && !isThumbhashString(s.uri)
)
.sort(
(a, b) => (a.webMaxViewportWidth ?? a.width ?? 0) - (b.webMaxViewportWidth ?? b.width ?? 0)
);
if (staticSupportedSources.length === 0) {
console.warn(
"You've set the `static` responsivePolicy but none of the sources have the `width` properties set. Make sure you set both `width` and `webMaxViewportWidth` for best results when using static responsiveness. Falling back to the `initial` policy."
);
return findBestSourceForSize(sources, size);
}
const srcset = staticSupportedSources
?.map((source) => `${source.uri} ${source.width}w`)
.join(', ');
const sizes = `${staticSupportedSources
?.map(getCSSMediaQueryForSource)
.join(', ')}, ${staticSupportedSources[staticSupportedSources.length - 1]?.width}px`;
return {
srcset,
sizes,
uri: staticSupportedSources[staticSupportedSources.length - 1]?.uri ?? '',
type: 'srcset',
};
}
export default function useSourceSelection(
sources: ImageSource[] | SharedRefType<'image'> | undefined,
responsivePolicy: ImageProps['responsivePolicy'] = 'static',
containerRef: React.RefObject<HTMLDivElement | null>,
measurementCallback: ((target: HTMLElement, size: DOMRect) => void) | null = null
): ImageSource | SrcSetSource | SharedRefType<'image'> | null {
const hasMoreThanOneSource = (Array.isArray(sources) ? sources.length : 0) > 1;
const [size, setSize] = useState<null | DOMRect>(
containerRef.current?.getBoundingClientRect() ?? null
);
if (size && containerRef.current) {
measurementCallback?.(containerRef.current, size);
}
React.useEffect(() => {
if ((!hasMoreThanOneSource && !measurementCallback) || !containerRef.current) {
return () => {};
}
if (responsivePolicy === 'live') {
const resizeObserver = new ResizeObserver((entries) => {
setSize(entries[0].contentRect);
measurementCallback?.(entries[0].target as any, entries[0].contentRect);
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}
return () => {};
}, [responsivePolicy, hasMoreThanOneSource, containerRef.current, measurementCallback]);
if (isImageRef(sources)) {
// There is always only one image ref, so there is nothing else to select from.
return sources;
}
return selectSource(sources, size, responsivePolicy);
}