react-native-blasted-image
Version:
A simple yet powerful image component for React Native, powered by Glide and SDWebImage
368 lines (321 loc) • 10.5 kB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { requireNativeComponent, NativeModules, Platform, Image, View } from 'react-native';
const LINKING_ERROR =
`The package 'react-native-blasted-image' doesn't seem to be linked. Make sure: \n\n` +
Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
'- You rebuilt the app after installing the package';
const NativeBlastedImage = NativeModules.BlastedImage
? NativeModules.BlastedImage
: new Proxy(
{},
{
get() {
throw new Error(LINKING_ERROR);
},
}
);
const BlastedImageView = requireNativeComponent('BlastedImageView');
const requestsCache = {};
export const loadImage = (imageUrl, skipMemoryCache = false, hybridAssets = false, cloudUrl = null, retries = 3) => {
if (typeof retries !== 'number' || retries <= 0) {
retries = 1;
}
if (hybridAssets && cloudUrl === null) {
console.error("When using hybridAssets, you must specify a cloudUrl prop. This is the base URL where the local assets are hosted.");
hybridAssets = false;
}
const cacheKey = `${imageUrl}::${!!skipMemoryCache}::${!!hybridAssets}::${cloudUrl || ''}`;
if (!requestsCache[cacheKey]) {
requestsCache[cacheKey] = new Promise(async (resolve, reject) => {
let wasRetried = false;
for (let attempt = 1; attempt <= retries; attempt++) {
// sleep for 1 second before retrying if attempt > 1
if (attempt > 1) {
wasRetried = true;
// await new Promise(resolve => setTimeout(resolve, 5000)); Keep for testing purposes
}
try {
await NativeBlastedImage.loadImage(imageUrl, skipMemoryCache, hybridAssets, cloudUrl);
resolve({ wasRetried });
return;
} catch (error) {
console.warn(`[BlastedImage] Attempt ${attempt} failed for ${imageUrl}`);
if (attempt === retries) {
delete requestsCache[cacheKey]; // Clear failed cache entry
reject(error);
}
}
}
});
}
return requestsCache[cacheKey];
};
const BlastedImage = ({
resizeMode = "cover",
isBackground = false,
returnSize = false,
fallbackSource = null,
tintColor = null,
retries = 3,
source,
width,
onLoad,
onError,
height,
style,
children
}) => {
const [error, setError] = useState(false);
const errorRef = useRef({});
const [renderKey, setRenderKey] = useState(null);
const isDoneRef = useRef(false);
if (typeof source === 'object') {
source = {
uri: '',
hybridAssets: false,
cloudUrl: null,
...source
};
if (source.hybridAssets && source.cloudUrl === null) {
console.error("When using hybridAssets, you must specify a cloudUrl prop. This is the base URL where the local assets are hosted.");
source.hybridAssets = false;
}
}
if (!source || (!source.uri && typeof source !== 'number')) {
if (!source) {
console.error("Source not specified for BlastedImage.");
} else {
console.error("Source should be either a URI <BlastedImage source={{ uri: 'https://example.com/image.jpg' }} /> or a local image using <BlastedImage source={ require('https://example.com/image.jpg') } />");
}
return null;
}
useEffect(() => {
if (typeof source === 'number' || (typeof source === 'object' && source.uri && source.uri.startsWith('file://'))) {
return;
}
// Check if this image URI already failed
if (errorRef.current[source.uri]) {
setError(true);
return;
}
if (isDoneRef.current) {
return;
}
fetchImage();
}, [source]);
// Callback for fetching image to not cause re-renders
const fetchImage = useCallback(async () => {
if (!source?.uri) {
console.error("Invalid source URI.");
return;
}
/*
try {
setError(false);
await loadImage(source.uri, false, source.hybridAssets, source.cloudUrl, retries);
onLoad?.();
} catch (err) {
setError(true);
errorRef.current[source.uri] = true;
console.error(`Failed to load image: ${source.uri}`, err);
onError?.(err);
}
*/
loadImage(source.uri, false, source.hybridAssets, source.cloudUrl, retries)
.then(({wasRetried}) => {
// Finally succeeded
isDoneRef.current = true;
if (wasRetried) {
const key = Math.random().toString(36).substring(2, 8);
setRenderKey(key);
}
setError(false);
//onLoad?.();
if (returnSize) {
Image.getSize(source.uri, (width, height) => {
onLoad?.({ width, height });
}, (error) => {
console.warn('[BlastedImage] Failed to get image size:', error);
onLoad?.(null);
});
} else {
onLoad?.();
}
})
.catch((err) => {
isDoneRef.current = true;
setError(true);
errorRef.current[source.uri] = true;
console.error(`Failed to load image: ${source.uri}`, err);
onError?.(err);
});
}, [source, retries]);
// Flatten styles if provided as an array, otherwise use style as-is
const flattenedStyle = Array.isArray(style) ? Object.assign({}, ...style) : style;
const defaultStyle = { overflow: 'hidden', position: 'relative', backgroundColor: style?.borderColor || 'transparent' }; // Use border color as background
const {
width: styleWidth, // Get width from style
height: styleHeight, // Get height from style
...remainingStyle // All other styles excluding above
} = flattenedStyle || {};
// Override width and height if they exist in style
width = width || styleWidth || 100; // First check the direct prop, then style, then default to 100
height = height || styleHeight || 100; // First check the direct prop, then style, then default to 100
const {
borderWidth = 0,
borderTopWidth = borderWidth,
borderBottomWidth = borderWidth,
borderLeftWidth = borderWidth,
borderRightWidth = borderWidth,
} = remainingStyle;
if (typeof width === 'string' && width.includes('%')) {
console.log("[BlastedImage] For maximum performance, BlastedImage does not support width defined as a percentage");
return;
}
if (typeof height === 'string' && height.includes('%')) {
console.log("[BlastedImage] For maximum performance, BlastedImage does not support height defined as a percentage");
return;
}
// Calculate the adjusted width and height
const adjustedWidth = width - (borderLeftWidth + borderRightWidth);
const adjustedHeight = height - (borderTopWidth + borderBottomWidth);
const viewStyle = {
...defaultStyle,
...remainingStyle,
width,
height,
};
const childrenStyle = {
position: 'absolute',
top: 0,
left: 0,
justifyContent:'center',
alignItems:'center',
width: adjustedWidth,
height: adjustedHeight,
};
return (
<View style={!isBackground ? viewStyle : null}>
{isBackground ? (
<View style={viewStyle}>
{renderImageContent(error, source, fallbackSource, tintColor, adjustedHeight, adjustedWidth, resizeMode, renderKey)}
</View>
) : (
renderImageContent(error, source, fallbackSource, tintColor, adjustedHeight, adjustedWidth, resizeMode, renderKey)
)}
{isBackground && <View style={childrenStyle}>{children}</View>}
</View>
);
};
function renderImageContent(error, source, fallbackSource, tintColor, adjustedHeight, adjustedWidth, resizeMode, renderKey) {
if (error) {
if (fallbackSource) { // Error - Fallback specified, use native component
return (
<Image
source={fallbackSource}
style={{ width: adjustedWidth, height: adjustedHeight }}
resizeMode={resizeMode}
tintColor={tintColor}
/>
);
} else { // Error - No fallback, use native component with static asset
return (
<Image
source={require('./assets/image-error.png')}
style={{ width: adjustedWidth, height: adjustedHeight }}
resizeMode={resizeMode}
tintColor={tintColor}
/>
);
}
} else if (typeof source === 'number') { // Success - with local asset (require), no need to use cache
return (
<Image
source={source}
style={{ width: adjustedWidth, height: adjustedHeight }}
resizeMode={resizeMode}
tintColor={tintColor}
/>
);
} else if (typeof source === 'object' && source.uri && source.uri.startsWith('file://')) { // Success - with local asset (file://android_asset), no need to use cache
return (
<Image
source={{ uri: source.uri }}
style={{ width: adjustedWidth, height: adjustedHeight }}
resizeMode={resizeMode}
tintColor={tintColor}
/>
);
} else { // Success - with remote asset (http/https), use native component with full cache support
return renderKey != null ? (
<BlastedImageView
key={renderKey} // Force re-render when image is retried
source={source}
width={adjustedWidth}
height={adjustedHeight}
resizeMode={resizeMode}
tintColor={tintColor}
/>
) : (
<BlastedImageView
source={source}
width={adjustedWidth}
height={adjustedHeight}
resizeMode={resizeMode}
tintColor={tintColor}
/>
);
}
}
// clear memory cache
BlastedImage.clearMemoryCache = () => {
return NativeBlastedImage.clearMemoryCache();
};
// clear disk cache
BlastedImage.clearDiskCache = () => {
return NativeBlastedImage.clearDiskCache();
};
// clear disk and memory cache
BlastedImage.clearAllCaches = () => {
return NativeBlastedImage.clearAllCaches();
};
BlastedImage.preload = (input, retries = 3) => {
return new Promise((resolve) => {
// single object
if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
loadImage(input.uri, input.skipMemoryCache, input.hybridAssets, input.cloudUrl, retries)
.then(() => {
resolve();
})
.catch((err) => {
console.error(`Error preloading single image: ${input.uri}`, err);
resolve(); // Count as handled even if failed to continue processing
});
}
// array
else if (Array.isArray(input)) {
let loadedCount = 0;
if (input.length === 0) {
resolve();
return;
}
input.forEach(image => {
loadImage(image.uri, image.skipMemoryCache, image.hybridAssets, image.cloudUrl, retries)
.then(() => {
loadedCount++;
if (loadedCount === input.length) {
resolve();
}
})
.catch((err) => {
console.error(`Error preloading one of the array images: ${image.uri}`, err);
loadedCount++; // Count as handled even if failed to continue processing
if (loadedCount === input.length) {
resolve();
}
});
});
}
});
};
export default BlastedImage;