You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Great library, I needed to extend some of the functionality, so I just did a rewrite for my purposes...
Here is what I have so far:
Ability to use the library without "blurring". Simply show a lower res version in front of the higher res version that is running. I didn't like the preview being behind the main image, because when the image would come...it would jump in front of the preview. Really, we want the preview to "fade" into the background, especially if we put the blurIntensity to zero.
Ability to have multiple previews. This is good for if you have xs,s,m,l sizes. Let's say you want l, but m is loaded in. Well...let's just use that as a preview!
useNativeDriver - I like to use native driver when we can especially on apps with lots of logic on the JS thread (like mine). I'd much rather sacrifice the "intensity" getting toned down, and just wrap it in an Animated.View and get the native driver. If specified to "true" it will use an Animated.View instead of animating the blur view directly.
Logging - Helps me understanding if it's actually working like it's supposed to. Pass in your function or just "true" and watch it log to console...
Rewrote the whole thing to use functional components and hooks.
Added async-mutex library to make sure that for every uri you download...you only download it once in parallel. This should prevent downloading the same image in parallel (if that was happening before).
TouchableWithoutFeedback would not work with this library because of this issue. It's now fixed.
Refactored the CacheManager to work with any type of files so you in theory could extend this to work for videos pretty easily.
We check for the directory when we download files (which in theory happens much less then serving it from the cache) and if it doesn't exist we create it then. This makes fetching from the cache 250-500ms faster.
Allow for changing the cached filename. For example if your files are https://google.com/image.jpg?token=34343 but you want https://google.com/image.jpg?token=12346 to just reuse the same file, you can change the way it makes the key when you configure things. See example below!
Add support for LoadingIndicator as well then pulls the current progress from the download!
import*asReactfrom"react";import*as_from"lodash";import*asFileSystemfrom"expo-file-system";importSHA1from"crypto-js/sha1";import{Image,Animated,StyleSheet,View,Platform,ImageStyle,ImageSourcePropType,StyleProp,ImageProps,ImageURISource,GestureResponderHandlers,}from"react-native";import{BlurView}from"expo-blur";import{useEffect,useRef,useState}from"react";importMutexfrom"async-mutex/lib/Mutex";import{FileSystemDownloadResult}from"expo-file-system/src/FileSystem.types";import{DownloadProgressData}from"expo-file-system";/// @aryk - Based off the react-native-expo-image-cache// https://github.com/wcandillon/react-native-expo-image-cache//// https://github.com/wcandillon/react-native-expo-image-cache/issues/168constblack="black";constwhite="white";constpropsToCopy=["borderRadius","borderBottomLeftRadius","borderBottomRightRadius","borderTopLeftRadius","borderTopRightRadius"];constisRemoteUri=(uri: string)=>Boolean(uri)&&uri.startsWith("http");interfaceDownloadOptions{md5?: boolean;headers?: {[name: string]: string};}typeStorageKeyFromUriType=(uri: string)=>string;interfaceCacheEntryOptionsextendsDownloadOptions{// Change the logic for where the local file is stored. This would be the place for example to chop off the// query string if you don't want it downloading a new version just because the query strings changed.storageKeyFromUri?: StorageKeyFromUriType;debug?: (str: string)=>any;directory: string;defaultExtension?: string;}interfaceICacheEntryResult{filename: string;ext: string;path: string;tmpPath: string;baseDir: string;ensureFolderExistsAsync: ()=>Promise<void>;getCachedAsync: ()=>Promise<string>;downloadAsync: (options?: {onProgress: (progress: number)=>any})=>Promise<string>;getCachedOrDownloadAsync: (options?: {onProgress: (progress: number)=>any})=>Promise<string>;}constCacheEntry=(uri: string,{
debug,
directory,defaultExtension: de,
storageKeyFromUri =uri=>uri,
...options}: CacheEntryOptions): ICacheEntryResult=>{constdownloadingMutex=newMutex();conststorageLocation=(()=>{constfilename=uri.substring(uri.lastIndexOf("/"),uri.indexOf("?")===-1 ? uri.length : uri.indexOf("?"));constext=filename.indexOf(".")===-1 ? (de ? `.${de}` : "") : filename.substring(filename.lastIndexOf("."));constpath=`${directory}${SHA1(storageKeyFromUri(uri))}${ext}`;consttmpPath=`${directory}${SHA1(uri)}-${_.uniqueId()}${ext}`;return{filename, ext, path, tmpPath,baseDir: directory};})();// Returns the local path to the asset.constgetCachedAsync=async()=>{const{path}=storageLocation;if((awaitFileSystem.getInfoAsync(path)).exists){debug?.(`Cache Hit: ${uri}`);returnpath;}else{returnundefined;}};constensureFolderExistsAsync=async()=>{if(!(awaitFileSystem.getInfoAsync(directory)).exists){debug?.(`Creating Directory: ${directory}`);// @aryk - Based on my testing this takes an average of 250-500ms to execute which makes the images load in slower// We should only be running this when we go to download the file initially and definitely not on retrieval from the cache.awaitFileSystem.makeDirectoryAsync(directory);}};const_downloadAsync=async({withRetry =true, onProgress =undefined}={})=>{debug?.(`Downloading [First Version]: ${uri}`);const{tmpPath, path}=storageLocation;letresult: FileSystemDownloadResult;try{constcallback=(data: DownloadProgressData)=>onProgress?.(data.totalBytesWritten/data.totalBytesExpectedToWrite);result=awaitFileSystem.createDownloadResumable(uri,tmpPath,options,callback).downloadAsync();}catch(e){if(withRetry){// If we get an error, we just assume it's because there is no directory...so make the directory and try again.awaitensureFolderExistsAsync();returnawait_downloadAsync({withRetry: false});}else{throwe;}}// If the image download failed, we don't cache anythingif(result&&result.status!==200){throw`Download Failed: ${JSON.stringify(result)}`;}else{awaitFileSystem.moveAsync({from: tmpPath,to: path});returnpath;}};// Downloads the asset but only one at a time.constdownloadAsync=async({onProgress})=>{if(downloadingMutex.isLocked()){debug?.(`Downloading [Finished Waiting]: ${uri}`);letinterval;try{if(onProgress){// If waiting for another download to finish...create a fake progress indicator here (better then nothing)letfakeProgress=0;interval=setInterval(()=>{fakeProgress+=0.1;onProgress(fakeProgress<1 ? fakeProgress : 1);},100);}awaitdownloadingMutex.waitForUnlock();}finally{clearInterval(interval);}onProgress?.(1);debug?.(`Downloading [Waiting]: ${uri}`);returngetCachedAsync();}else{returnawaitdownloadingMutex.runExclusive(()=>_downloadAsync({onProgress}));}};// Check first if it is local, otherwise download it.constgetCachedOrDownloadAsync=async({onProgress})=>{constpath=awaitgetCachedAsync();if(path){debug?.(`Cache Hit: ${uri}`);returnpath;}else{returnawaitdownloadAsync({onProgress});}};return{
...storageLocation,
ensureFolderExistsAsync,
getCachedAsync,
downloadAsync,
getCachedOrDownloadAsync,};};interfaceICreateLocalFileCacheResult{get: (uri: string,options?: Omit<CacheEntryOptions,"directory">)=>ICacheEntryResult;directory: string;clearAsync: ()=>Promise<any>;sizeAsync: ()=>Promise<number>;}interfaceICreateLocalFileCacheextendsOmit<CacheEntryOptions,"directory">{directoryName?: string;}// @aryk - This is completely abstracted away from the usage for images. You can use it for files, vidoes, etc.constcreateLocalFileCache=({directoryName ="local-file-cache", ...cacheEntryOptions}: ICreateLocalFileCache={})=>{constentries:{[uri: string]: ICacheEntryResult}={};constdirectory=`${FileSystem.cacheDirectory}${directoryName}/`;constget=(uri: string,options: Omit<CacheEntryOptions,"directory">={}): ICacheEntryResult=>{if(!entries[uri]){entries[uri]=CacheEntry(uri,{directory, ...cacheEntryOptions, ...options});}returnentries[uri];};return{
get,
directory,clearAsync: async()=>{awaitFileSystem.deleteAsync(directory,{idempotent: true});awaitFileSystem.makeDirectoryAsync(directory);},sizeAsync: async()=>{constresult=awaitFileSystem.getInfoAsync(directory);if(!result.exists){thrownewError(`${directory} not found`);}returnresult.size;},}};constimageCacheRef=React.createRef<ICreateLocalFileCacheResult>();constdefaultGetCacheEntry=(uri,options)=>imageCacheRef.current.get(uri,options);constsetDefaultImageCache=(cache: ICreateLocalFileCacheResult)=>{(imageCacheRefasany).current=cache;};typeErrorType={nativeEvent: {error: Error}};// compatible with the ImageProps error.interfaceCachedImagePropsextendsOmit<ImageProps,"source">,GestureResponderHandlers{style?: StyleProp<ImageStyle>;// Pass in previews in order from least desired to most desired. So the last preview should be maybe your medium// size image if you are trying to present your "large".previews?: ImageSourcePropType[];options?: DownloadOptions;transitionDuration?: number;tint?: "dark"|"light";onError?: (error: ErrorType)=>void;uri: string;blurIntensity?: number;// In case you want to bypass the cache like to test your progress indicatorbypassCache?: boolean;debug?: boolean|((str: string)=>any);useNativeDriver?: boolean;getCacheEntry?: ICreateLocalFileCacheResult["get"];ProgressIndicatorComponent?: React.ComponentType<{progress?: number}>;}constCachedImage=({
uri,
transitionDuration =100,
tint ="dark",onError: _onError,
style,
defaultSource,
previews,
blurIntensity =100,options: _options={},debug: _debug,
useNativeDriver =true,
getCacheEntry =defaultGetCacheEntry,
bypassCache,
ProgressIndicatorComponent,// We take all the responder props and move it onto the View to make sure it works with TouchableWithoutFeedback// Why? https://github.com/facebook/react-native/issues/1352#issuecomment-106938999
onStartShouldSetResponder,
onMoveShouldSetResponder,
onResponderEnd,
onResponderGrant,
onResponderReject,
onResponderMove,
onResponderRelease,
onResponderStart,
onResponderTerminationRequest,
onResponderTerminate,
onStartShouldSetResponderCapture,
onMoveShouldSetResponderCapture,
...props}: CachedImageProps)=>{const[cachedUri,_setCachedUri]=useState<string>();const[hasLocal,setHasLocal]=useState<boolean>();const[progress,setProgress]=useState<number>(0);const[_preview,setPreview]=useState<ImageSourcePropType>();const[previewOpacity]=useState(newAnimated.Value(1));constmountedRef=useRef<boolean>(true);// @aryk - We only use the preview if we checked the main uri and determined that it isn't local...otherwise we want to// sshow the image immediately. We still want to check the previews for what is local as well in parallel, but we// don't use it unless we need to.constpreview=hasLocal===false ? _preview : undefined;constdebug=_debug===true ? console.log : (_debug||undefined);constoptions={debug, ..._options};// If we don't have a progress indicator, no reason to set the progress and trigger state updates.constonProgress=ProgressIndicatorComponent ? setProgress : undefined;constonError=(error: Error)=>{_onError?.({nativeEvent: {error}});debug?.(JSON.stringify(error));};useEffect(()=>{if(previews){debug?.(`New previews: ${previews.length}`);(async()=>{try{// Go through and try to look for all the possible previews including cached images (maybe smaller thumbnails)constimageSources: ImageSourcePropType[]=awaitPromise.all(previews.map(asyncpreview=>{if(typeof(preview)==="object"&&!Array.isArray(preview)&&isRemoteUri((previewasImageURISource).uri)){consturi=awaitgetCacheEntry((previewasImageURISource).uri,options).getCachedAsync();returnuri ? {...(previewasImageURISource), uri} : undefined;}else{returnpreview;}}));constpreview=_.last(imageSources.filter(x=>x));if(preview){debug?.(`Setting preview: ${JSON.stringify(preview)}\nFor ${uri}`);setPreview(preview);}}catch(e){onError(e);}})();}},[previews],);constsetCachedUri=(u: string)=>{_setCachedUri(u);debug?.(`Setting cached uri preview: ${u}`);if(!cachedUri){Animated.timing(previewOpacity,{duration: transitionDuration,toValue: 0,
useNativeDriver,}).start();}};useEffect(()=>{if(uri){(async()=>{try{constcacheEntry=getCacheEntry(uri,options);letcachedUri=isRemoteUri(uri) ? (bypassCache ? null : awaitcacheEntry.getCachedAsync()) : uri;// If we were able to get it from the cache or it's already a local image like base64 or file://, then set this// so that we don't go and try to load the previews in tandem with the cached image.setHasLocal(Boolean(cachedUri));if(!cachedUri){cachedUri=awaitcacheEntry.downloadAsync({onProgress});}if(mountedRef.current){if(cachedUri){setCachedUri(cachedUri);}else{onError(newError(`Could not load image: ${uri}`));}}}catch(e){onError(e);}})()}},[uri],// only want this to run when uri changes, everything else will get the new value at the time that "uri" changes.);useEffect(()=>()=>{mountedRef.current=false;},[]);constisImageReady=Boolean(cachedUri);constblurPreview=Boolean(blurIntensity);// Sometimes the previews come in as an empty array at first (only to get populated later on a state change). In this event,// we still want to enable the blur view from the very beginning before showing the subsequent image.constenablePreviews=Boolean(previews);constflattenedStyle=StyleSheet.flatten(style);constcomputedStyle: StyleProp<ImageStyle>=[StyleSheet.absoluteFill,_.transform(_.pickBy(flattenedStyle,(_val,key)=>propsToCopy.indexOf(key)!==-1),(result,value: any,key)=>Object.assign(result,{[key]: value-(flattenedStyle.borderWidth||0)}))];return<View{...{
style,
onStartShouldSetResponder,
onMoveShouldSetResponder,
onResponderEnd,
onResponderGrant,
onResponderReject,
onResponderMove,
onResponderRelease,
onResponderStart,
onResponderTerminationRequest,
onResponderTerminate,
onStartShouldSetResponderCapture,
onMoveShouldSetResponderCapture,}}>{!!defaultSource&&!isImageReady&&<Imagesource={defaultSource}style={computedStyle}{...props}/>}{isImageReady&&<Imagesource={{uri: cachedUri}}style={computedStyle}onLoadStart={()=>debug?.(`Loading (started): ${cachedUri}`)}onLoadEnd={()=>debug?.(`Loading (finished): ${cachedUri}`)}{...props}/>}{
enablePreviews &&<>{preview&&<Animated.Imagesource={preview}style={[computedStyle,{opacity: previewOpacity}]}blurRadius={Platform.OS==="android"&&blurPreview ? 0.5 : 0}{...props}/>}{blurPreview&&(Platform.OS==="ios"&&<Animated.Viewstyle={[computedStyle,{opacity: previewOpacity}]}><BlurViewstyle={computedStyle}tint={tint}intensity={blurIntensity}/></Animated.View>)||(Platform.OS==="android"&&<Animated.Viewstyle={[computedStyle,{backgroundColor: tint==="dark" ? black : white,opacity: previewOpacity.interpolate({inputRange: [0,1],// 200 would basically be full blur, so just divide itoutputRange: [0,blurIntensity/200]}),}]}/>)}</>}{// We only want to show the progress indicator if we have a non zero progress...
ProgressIndicatorComponent &&Boolean(progress)&&<Animated.Viewstyle={[computedStyle,{alignItems: "center",justifyContent: "center",opacity: previewOpacity},]}><ProgressIndicatorComponentprogress={progress}/></Animated.View>}</View>;};export{
createLocalFileCache,
DownloadOptions,
ICacheEntryResult,
CacheEntry,
CachedImage,
CachedImageProps,
setDefaultImageCache,};
The text was updated successfully, but these errors were encountered:
@wcandillon Im currently launching my startup so I'm ridiculously busy. I'm looking to hire a senior engineer so once I do that, maybe I can hand the package over to them.
@wcandillon are you not using this library anymore? Were you able to get on EAS builds with fast-image. Would be curious to here your story.
I'm having trouble getting EAS running and don't have the expertise to write some config plugins I need to get my app working so I'm kind of bunting on that for now.
Aryk
changed the title
Logging, Change Intensity, Allow Multiple Previews, Check if File Exists in Cache
Rewrite: Logging, Change Intensity, Allow Multiple Previews, Check if File Exists in Cache
Nov 20, 2021
I haven't touched the topic of images in a while, so I'm out of the loop with how we would do these things today. And I thought your summary was great.
Great library, I needed to extend some of the functionality, so I just did a rewrite for my purposes...
Here is what I have so far:
https://google.com/image.jpg?token=34343
but you wanthttps://google.com/image.jpg?token=12346
to just reuse the same file, you can change the way it makes the key when you configure things. See example below!The text was updated successfully, but these errors were encountered: