diff --git a/README.md b/README.md index 6b909e19c..034d9e0e0 100644 --- a/README.md +++ b/README.md @@ -212,21 +212,25 @@ If supplied, changes the color of all the non-transparent pixels to the given co ## Static Methods -### `FastImage.preload: (source[]) => void` +### `FastImage.preload: (source[], onProgress?, onComplete?) => void` Preload images to display later. e.g. ```js -FastImage.preload([ +FastImage.preload( + [ { - uri: 'https://facebook.github.io/react/img/logo_og.png', - headers: { Authorization: 'someAuthToken' }, + uri: 'https://facebook.github.io/react/img/logo_og.png', + headers: { Authorization: 'someAuthToken' }, }, { - uri: 'https://facebook.github.io/react/img/logo_og.png', - headers: { Authorization: 'someAuthToken' }, + uri: 'https://facebook.github.io/react/img/logo_og.png', + headers: { Authorization: 'someAuthToken' }, }, -]) + ], + (finished, total) => console.log(`Preloaded ${finished}/${total} images`), + (finished, skipped) => console.log(`Completed. Failed to load ${skipped}/${finished} images`), +) ``` ### `FastImage.clearMemoryCache: () => Promise` diff --git a/android/src/main/java/com/dylanvann/fastimage/FastImagePreloaderListener.java b/android/src/main/java/com/dylanvann/fastimage/FastImagePreloaderListener.java new file mode 100644 index 000000000..5e45ce76b --- /dev/null +++ b/android/src/main/java/com/dylanvann/fastimage/FastImagePreloaderListener.java @@ -0,0 +1,73 @@ +package com.dylanvann.fastimage; + +import android.util.Log; + +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.target.Target; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +import java.io.File; + +class FastImagePreloaderListener implements RequestListener { + private static final String LOG = "[FFFastImage]"; + private static final String EVENT_PROGRESS = "fffastimage-progress"; + private static final String EVENT_COMPLETE = "fffastimage-complete"; + + private final ReactApplicationContext reactContext; + private final int id; + private final int total; + private int succeeded = 0; + private int failed = 0; + + public FastImagePreloaderListener(ReactApplicationContext reactContext, int id, int totalImages) { + this.id = id; + this.reactContext = reactContext; + this.total = totalImages; + } + + @Override + public boolean onLoadFailed(@androidx.annotation.Nullable GlideException e, Object o, Target target, boolean b) { + // o is whatever was passed to .load() = GlideURL, String, etc. + Log.d(LOG, "Preload failed: " + o.toString()); + this.failed++; + this.dispatchProgress(); + return false; + } + + @Override + public boolean onResourceReady(File file, Object o, Target target, DataSource dataSource, boolean b) { + // o is whatever was passed to .load() = GlideURL, String, etc. + Log.d(LOG, "Preload succeeded: " + o.toString()); + this.succeeded++; + this.dispatchProgress(); + return false; + } + + private void maybeDispatchComplete() { + if (this.failed + this.succeeded >= this.total) { + WritableMap params = Arguments.createMap(); + params.putInt("id", this.id); + params.putInt("finished", this.succeeded + this.failed); + params.putInt("skipped", this.failed); + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(EVENT_COMPLETE, params); + } + } + + private void dispatchProgress() { + WritableMap params = Arguments.createMap(); + params.putInt("id", this.id); + params.putInt("finished", this.succeeded + this.failed); + params.putInt("total", this.total); + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(EVENT_PROGRESS, params); + this.maybeDispatchComplete(); + } +} \ No newline at end of file diff --git a/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java b/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java index f9d6faad7..3b79e4022 100644 --- a/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java +++ b/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java @@ -16,7 +16,8 @@ class FastImageViewModule extends ReactContextBaseJavaModule { - private static final String REACT_CLASS = "FastImageView"; + private static final String REACT_CLASS = "FastImagePreloaderManager"; + private int preloaders = 0; FastImageViewModule(ReactApplicationContext reactContext) { super(reactContext); @@ -29,18 +30,25 @@ public String getName() { } @ReactMethod - public void preload(final ReadableArray sources) { + public void createPreloader(Promise promise) { + promise.resolve(preloaders++); + } + + @ReactMethod + public void preload(final int preloaderId, final ReadableArray sources) { final Activity activity = getCurrentActivity(); if (activity == null) return; activity.runOnUiThread(new Runnable() { @Override public void run() { + FastImagePreloaderListener preloader = new FastImagePreloaderListener(getReactApplicationContext(), preloaderId, sources.size()); for (int i = 0; i < sources.size(); i++) { final ReadableMap source = sources.getMap(i); final FastImageSource imageSource = FastImageViewConverter.getImageSource(activity, source); Glide .with(activity.getApplicationContext()) + .downloadOnly() // This will make this work for remote and local images. e.g. // - file:/// // - content:// @@ -51,6 +59,7 @@ public void run() { imageSource.isBase64Resource() ? imageSource.getSource() : imageSource.isResource() ? imageSource.getUri() : imageSource.getGlideUrl() ) + .listener(preloader) .apply(FastImageViewConverter.getOptions(activity, imageSource, source)) .preload(); } diff --git a/ios/FastImage.xcodeproj/project.pbxproj b/ios/FastImage.xcodeproj/project.pbxproj index 5104e2b2a..09742ab67 100644 --- a/ios/FastImage.xcodeproj/project.pbxproj +++ b/ios/FastImage.xcodeproj/project.pbxproj @@ -79,6 +79,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 7547098A212F3BE70040708C /* FFFastImagePreloaderManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FFFastImagePreloaderManager.h; sourceTree = ""; }; + 75470990212F3C590040708C /* FFFastImagePreloaderManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FFFastImagePreloaderManager.m; sourceTree = ""; }; + 75470992212F3F9A0040708C /* FFFastImagePreloader.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FFFastImagePreloader.h; sourceTree = ""; }; + 75470993212F409B0040708C /* FFFastImagePreloader.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FFFastImagePreloader.m; sourceTree = ""; }; A287971D1DE0C0A60081BDFA /* libFastImage.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libFastImage.a; sourceTree = BUILT_PRODUCTS_DIR; }; FCFB25371EA5562700F59778 /* FFFastImageSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FFFastImageSource.h; sourceTree = ""; }; FCFB25381EA5562700F59778 /* FFFastImageSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FFFastImageSource.m; sourceTree = ""; }; @@ -149,6 +153,10 @@ FCFB253C1EA5562700F59778 /* FFFastImageViewManager.m */, FCFB253D1EA5562700F59778 /* RCTConvert+FFFastImage.h */, FCFB253E1EA5562700F59778 /* RCTConvert+FFFastImage.m */, + 7547098A212F3BE70040708C /* FFFastImagePreloaderManager.h */, + 75470990212F3C590040708C /* FFFastImagePreloaderManager.m */, + 75470992212F3F9A0040708C /* FFFastImagePreloader.h */, + 75470993212F409B0040708C /* FFFastImagePreloader.m */, ); path = FastImage; sourceTree = ""; @@ -261,10 +269,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 75470994212F409B0040708C /* FFFastImagePreloader.m in Sources */, FCFB25411EA5562700F59778 /* FFFastImageViewManager.m in Sources */, FCFB25421EA5562700F59778 /* RCTConvert+FFFastImage.m in Sources */, FCFB25401EA5562700F59778 /* FFFastImageView.m in Sources */, FCFB253F1EA5562700F59778 /* FFFastImageSource.m in Sources */, + 75470991212F3C590040708C /* FFFastImagePreloaderManager.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/FastImage/FFFastImagePreloader.h b/ios/FastImage/FFFastImagePreloader.h new file mode 100644 index 000000000..29fbb8f80 --- /dev/null +++ b/ios/FastImage/FFFastImagePreloader.h @@ -0,0 +1,9 @@ +#import "FFFastImageSource.h" +#import +#import + +@interface FFFastImagePreloader : SDWebImagePrefetcher + +@property (nonatomic, readonly) NSNumber* id; + +@end diff --git a/ios/FastImage/FFFastImagePreloader.m b/ios/FastImage/FFFastImagePreloader.m new file mode 100644 index 000000000..085541b1f --- /dev/null +++ b/ios/FastImage/FFFastImagePreloader.m @@ -0,0 +1,16 @@ +#import "FFFastImagePreloader.h" +#import "FFFastImageSource.h" + +static int instanceCounter = 0; + +@implementation FFFastImagePreloader + +-(instancetype) init { + if (self = [super init]) { + instanceCounter ++; + _id = [NSNumber numberWithInt:instanceCounter]; + } + return self; +} + +@end diff --git a/ios/FastImage/FFFastImagePreloaderManager.h b/ios/FastImage/FFFastImagePreloaderManager.h new file mode 100644 index 000000000..830450cff --- /dev/null +++ b/ios/FastImage/FFFastImagePreloaderManager.h @@ -0,0 +1,7 @@ +#import +#import +#import + +@interface FFFastImagePreloaderManager : RCTEventEmitter + +@end diff --git a/ios/FastImage/FFFastImagePreloaderManager.m b/ios/FastImage/FFFastImagePreloaderManager.m new file mode 100644 index 000000000..742dc3cd5 --- /dev/null +++ b/ios/FastImage/FFFastImagePreloaderManager.m @@ -0,0 +1,79 @@ +#import "FFFastImagePreloaderManager.h" +#import "FFFastImagePreloader.h" +#import "FFFastImageSource.h" +#import "SDWebImageDownloader.h" + +@implementation FFFastImagePreloaderManager +{ + bool _hasListeners; + NSMutableDictionary* _preloaders; +} + +RCT_EXPORT_MODULE(FastImagePreloaderManager); + +- (dispatch_queue_t)methodQueue +{ + return dispatch_queue_create("com.dylanvann.fastimage.FastImagePreloaderManager", DISPATCH_QUEUE_SERIAL); +} + ++ (BOOL)requiresMainQueueSetup +{ + return YES; +} + +-(instancetype) init { + if (self = [super init]) { + _preloaders = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (NSArray *)supportedEvents +{ + return @[@"fffastimage-progress", @"fffastimage-complete"]; +} + +- (void) imagePrefetcher:(nonnull SDWebImagePrefetcher *)imagePrefetcher + didFinishWithTotalCount:(NSUInteger)totalCount + skippedCount:(NSUInteger)skippedCount +{ + NSNumber* id = ((FFFastImagePreloader*) imagePrefetcher).id; + [_preloaders removeObjectForKey:id]; + [self sendEventWithName:@"fffastimage-complete" + body:@{ @"id": id, @"finished": [NSNumber numberWithLong:totalCount], @"skipped": [NSNumber numberWithLong:skippedCount]} + ]; +} + +- (void) imagePrefetcher:(nonnull SDWebImagePrefetcher *)imagePrefetcher + didPrefetchURL:(nullable NSURL *)imageURL + finishedCount:(NSUInteger)finishedCount + totalCount:(NSUInteger)totalCount +{ + NSNumber* id = ((FFFastImagePreloader*) imagePrefetcher).id; + [self sendEventWithName:@"fffastimage-progress" + body:@{ @"id": id, @"finished": [NSNumber numberWithLong:finishedCount], @"total": [NSNumber numberWithLong:totalCount] } + ]; +} + +RCT_EXPORT_METHOD(createPreloader:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) { + FFFastImagePreloader* preloader = [[FFFastImagePreloader alloc] init]; + preloader.delegate = self; + _preloaders[preloader.id] = preloader; + resolve(preloader.id); +} + +RCT_EXPORT_METHOD(preload:(nonnull NSNumber*)preloaderId sources:(nonnull NSArray *)sources) { + NSMutableArray *urls = [NSMutableArray arrayWithCapacity:sources.count]; + + [sources enumerateObjectsUsingBlock:^(FFFastImageSource * _Nonnull source, NSUInteger idx, BOOL * _Nonnull stop) { + [source.headers enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSString* header, BOOL *stop) { + [[SDWebImageDownloader sharedDownloader] setValue:header forHTTPHeaderField:key]; + }]; + [urls setObject:source.url atIndexedSubscript:idx]; + }]; + + FFFastImagePreloader* preloader = _preloaders[preloaderId]; + [preloader prefetchURLs:urls]; +} + +@end diff --git a/src/PreloaderManager.tsx b/src/PreloaderManager.tsx new file mode 100644 index 000000000..8b2acca2d --- /dev/null +++ b/src/PreloaderManager.tsx @@ -0,0 +1,87 @@ +import { NativeEventEmitter, NativeModules } from 'react-native' + +import type { EmitterSubscription } from 'react-native' + +import type { + Source, + PreloadProgressHandler, + PreloadCompletionHandler, +} from './index' + +const nativeManager = NativeModules.FastImagePreloaderManager +const nativeEmitter = new NativeEventEmitter(nativeManager) + +type PreloadCallbacks = { + onProgress?: PreloadProgressHandler + onComplete?: PreloadCompletionHandler +} + +type OnProgressParams = { + id: number + finished: number + total: number +} + +type OnCompleteParams = { + id: number + finished: number + skipped: number +} + +class PreloaderManager { + _instances: Map = new Map() + _subProgress!: EmitterSubscription + _subComplete!: EmitterSubscription + + preload( + sources: Source[], + onProgress?: PreloadProgressHandler, + onComplete?: PreloadCompletionHandler, + ) { + nativeManager.createPreloader().then((id: number) => { + if (this._instances.size === 0) { + this._subProgress = nativeEmitter.addListener( + 'fffastimage-progress', + this.onProgress.bind(this), + ) + this._subComplete = nativeEmitter.addListener( + 'fffastimage-complete', + this.onComplete.bind(this), + ) + } + + this._instances.set(id, { onProgress, onComplete }) + + nativeManager.preload(id, sources) + }) + } + + onProgress({ id, finished, total }: OnProgressParams) { + const instance = this._instances.get(id) + + if (instance && instance.onProgress) + instance.onProgress(finished, total) + } + + onComplete({ id, finished, skipped }: OnCompleteParams) { + const instance = this._instances.get(id) + + if (instance && instance.onComplete) + instance.onComplete(finished, skipped) + + this._instances.delete(id) + + if ( + this._instances.size === 0 && + this._subProgress && + this._subComplete + ) { + this._subProgress.remove() + this._subComplete.remove() + } + } +} + +const preloaderManager = new PreloaderManager() + +export default preloaderManager \ No newline at end of file diff --git a/src/index.js.flow b/src/index.js.flow index 8124c868a..53257c0a7 100644 --- a/src/index.js.flow +++ b/src/index.js.flow @@ -40,7 +40,7 @@ export type ResizeModes = $Values export type Priorities = $Values export type CacheControls = $Values -export type PreloadFn = (sources: Array) => void +export type PreloadFn = (sources: Array, onProgress: Function, onComplete: Function) => void export type FastImageSource = { uri?: string, headers?: Object, diff --git a/src/index.test.tsx b/src/index.test.tsx index c53c10886..00c91f74f 100644 --- a/src/index.test.tsx +++ b/src/index.test.tsx @@ -3,6 +3,10 @@ import React from 'react' import renderer from 'react-test-renderer' import FastImage from './index' +jest.mock( + '../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitter', +) + const style = StyleSheet.create({ image: { width: 44, height: 44 } }) describe('FastImage (iOS)', () => { diff --git a/src/index.tsx b/src/index.tsx index 478c629e9..fb1d9681e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,8 @@ import { ColorValue, } from 'react-native' +import preloaderManager from './PreloaderManager' + export type ResizeMode = 'contain' | 'cover' | 'stretch' | 'center' const resizeMode = { @@ -130,6 +132,14 @@ export interface FastImageProps extends AccessibilityProps, ViewProps { children?: React.ReactNode } +export interface PreloadProgressHandler { + (loaded: number, total: number): void +} + +export interface PreloadCompletionHandler { + (loaded: number, skipped: number): void +} + const resolveDefaultSource = ( defaultSource?: ImageRequireSource, ): string | number | null => { @@ -231,7 +241,11 @@ export interface FastImageStaticProperties { resizeMode: typeof resizeMode priority: typeof priority cacheControl: typeof cacheControl - preload: (sources: Source[]) => void + preload: ( + sources: Source[], + onProgress?: PreloadProgressHandler, + onComplete?: PreloadCompletionHandler, + ) => void clearMemoryCache: () => Promise clearDiskCache: () => Promise } @@ -245,8 +259,11 @@ FastImage.cacheControl = cacheControl FastImage.priority = priority -FastImage.preload = (sources: Source[]) => - NativeModules.FastImageView.preload(sources) +FastImage.preload = ( + sources: Source[], + onProgress?: PreloadProgressHandler, + onComplete?: PreloadCompletionHandler, +) => preloaderManager.preload(sources, onProgress, onComplete) FastImage.clearMemoryCache = () => NativeModules.FastImageView.clearMemoryCache()