react-native-fast-image 8.6.43 → 8.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,8 @@ If you use Proguard you will need to add these lines to `android/app/proguard-ru
112
112
 
113
113
  Source for the remote image to load.
114
114
 
115
+ When `source` changes, the image that's showing stays until the new one has loaded, as with `<img>` in browsers and React Native's `Image` on iOS. In views that get reused for other content, such as rows in FlashList or recyclerlistview, set `recyclingKey` so a reused row doesn't show the previous row's image.
116
+
115
117
  ---
116
118
 
117
119
  ### `source.uri?: string`
@@ -147,10 +149,21 @@ Indicates the load order priority of an image. Images with `FastImage.priority.h
147
149
  ### `defaultSource?: number`
148
150
 
149
151
  - An asset loaded with `require(...)`.
152
+ - Shown while the first image loads, and if an image fails to load. When `source` changes, the previous image shows while the new one loads instead (see `source` and `recyclingKey`).
150
153
  - Note that like the built-in `Image` implementation, on Android `defaultSource` does not work in debug mode. This is due to the fact that assets are sent from the dev server, but RN's functions only know how to load it from `res`.
151
154
 
152
155
  ---
153
156
 
157
+ ### `recyclingKey?: string`
158
+
159
+ For views that get reused for other content, such as rows in FlashList or recyclerlistview. Set it to something that identifies the content, e.g. the item's id. When it changes, the image is cleared right away (to `defaultSource`, or blank) instead of staying until the new one has loaded. Unlike changing `key`, this keeps the view, which is what list recycling is for.
160
+
161
+ ```jsx
162
+ <FastImage recyclingKey={item.id} source={{ uri: item.imageUrl }} />
163
+ ```
164
+
165
+ ---
166
+
154
167
  ### `resizeMode?: enum`
155
168
 
156
169
  - `FastImage.resizeMode.contain` - Scale the image uniformly (maintain the image's aspect ratio) so that both dimensions (width and height) of the image will be equal to or less than the corresponding dimension of the view (minus padding).
@@ -213,7 +226,7 @@ If supplied, changes the color of all the non-transparent pixels to the given co
213
226
 
214
227
  ## Static Methods
215
228
 
216
- ### `FastImage.preload: (source[]) => void`
229
+ ### `FastImage.preload: (source[]) => Promise<result[]>`
217
230
 
218
231
  Preload images to display later. e.g.
219
232
 
@@ -230,6 +243,13 @@ FastImage.preload([
230
243
  ])
231
244
  ```
232
245
 
246
+ It resolves when all the images have loaded or failed, with a result for each source, in order: `{ uri, ok, width, height, error }`. `width` and `height` are the image's size (as in `onLoad`), which is also a way to find an image's size without showing it. A few sources load at a time (3, or `SDWebImagePrefetcher`'s `maxConcurrentPrefetchCount` on iOS), so a long list doesn't hold up the images the app is showing, and at low priority unless the source sets `priority`. It never rejects:
247
+
248
+ ```js
249
+ const results = await FastImage.preload(sources)
250
+ const failed = results.filter((result) => !result.ok)
251
+ ```
252
+
233
253
  ### `FastImage.clearMemoryCache: () => Promise<void>`
234
254
 
235
255
  Clear all images from memory cache.
@@ -19,19 +19,28 @@ public class FastImageRequestListener implements RequestListener<Drawable> {
19
19
  static final String REACT_ON_LOAD_END_EVENT = "onFastImageLoadEnd";
20
20
  private final String key;
21
21
  private final FastImageSource source;
22
+ // Whether the request shows the previous image as a thumbnail meanwhile.
23
+ private final boolean thumbnail;
24
+ // False when loading the image that's showing again at a new size: the
25
+ // view shows it at its old size if that fails.
26
+ private final boolean events;
22
27
 
23
- FastImageRequestListener(String key, FastImageSource source) {
28
+ FastImageRequestListener(String key, FastImageSource source, boolean thumbnail, boolean events) {
24
29
  this.key = key;
25
30
  this.source = source;
31
+ this.thumbnail = thumbnail;
32
+ this.events = events;
26
33
  }
27
34
 
28
35
  @Override
29
36
  public boolean onLoadFailed(@androidx.annotation.Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
37
+ if (!events) return false;
30
38
  FastImageOkHttpProgressGlideModule.forget(key);
31
39
  if (!(target instanceof ImageViewTarget)) {
32
40
  return false;
33
41
  }
34
42
  FastImageViewWithUrl view = (FastImageViewWithUrl) ((ImageViewTarget) target).getView();
43
+ view.onImageFailed(thumbnail);
35
44
  FastImageEvents.send(view, REACT_ON_ERROR_EVENT);
36
45
  FastImageEvents.send(view, REACT_ON_LOAD_END_EVENT);
37
46
  return false;
@@ -43,11 +52,13 @@ public class FastImageRequestListener implements RequestListener<Drawable> {
43
52
  return false;
44
53
  }
45
54
  final FastImageViewWithUrl view = (FastImageViewWithUrl) ((ImageViewTarget) target).getView();
55
+ view.onImageLoaded();
46
56
  if (resource instanceof GifDrawable) {
47
57
  // Play the GIF as many times as the file says, as iOS does. Glide
48
58
  // loops every GIF forever by default (#651).
49
59
  ((GifDrawable) resource).setLoopCount(GifDrawable.LOOP_INTRINSIC);
50
60
  }
61
+ if (!events) return false;
51
62
  boolean local = !(model instanceof GlideUrl);
52
63
  int[] size = FastImageSourceSize.get(resource, model, local,
53
64
  dataSource == DataSource.RESOURCE_DISK_CACHE);
@@ -84,6 +84,11 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl> imple
84
84
  }
85
85
  }
86
86
 
87
+ @ReactProp(name = "recyclingKey")
88
+ public void setRecyclingKey(FastImageViewWithUrl view, @Nullable String recyclingKey) {
89
+ view.setRecyclingKey(recyclingKey);
90
+ }
91
+
87
92
  @ReactProp(name = "resizeMode")
88
93
  public void setResizeMode(FastImageViewWithUrl view, String resizeMode) {
89
94
  final FastImageViewWithUrl.ScaleType scaleType = FastImageViewConverter.getScaleType(resizeMode);
@@ -1,24 +1,34 @@
1
1
  package com.dylanvann.fastimage;
2
2
 
3
3
  import android.app.Activity;
4
- import android.util.Log;
4
+ import android.graphics.drawable.Drawable;
5
5
 
6
6
  import androidx.annotation.NonNull;
7
+ import androidx.annotation.Nullable;
7
8
 
8
9
  import com.bumptech.glide.Glide;
9
- import com.bumptech.glide.load.model.GlideUrl;
10
+ import com.bumptech.glide.Priority;
11
+ import com.bumptech.glide.load.DataSource;
12
+ import com.bumptech.glide.load.engine.GlideException;
13
+ import com.bumptech.glide.request.RequestListener;
14
+ import com.bumptech.glide.request.RequestOptions;
15
+ import com.bumptech.glide.request.target.Target;
16
+ import com.facebook.react.bridge.Arguments;
10
17
  import com.facebook.react.bridge.Promise;
11
18
  import com.facebook.react.bridge.ReactApplicationContext;
12
19
  import com.facebook.react.bridge.ReactContextBaseJavaModule;
13
20
  import com.facebook.react.bridge.ReactMethod;
14
21
  import com.facebook.react.bridge.ReadableArray;
15
22
  import com.facebook.react.bridge.ReadableMap;
16
- import com.facebook.react.views.imagehelper.ImageSource;
23
+ import com.facebook.react.bridge.UiThreadUtil;
24
+ import com.facebook.react.bridge.WritableArray;
25
+ import com.facebook.react.bridge.WritableMap;
26
+
27
+ import java.util.ArrayDeque;
17
28
 
18
29
  class FastImageViewModule extends ReactContextBaseJavaModule {
19
30
 
20
31
  private static final String REACT_CLASS = "FastImageView";
21
- private static final String TAG = "FastImage";
22
32
 
23
33
  FastImageViewModule(ReactApplicationContext reactContext) {
24
34
  super(reactContext);
@@ -30,41 +40,141 @@ class FastImageViewModule extends ReactContextBaseJavaModule {
30
40
  return REACT_CLASS;
31
41
  }
32
42
 
43
+ // At most this many preloaded sources load at a time, across all preload
44
+ // calls (the same as SDWebImagePrefetcher's default on iOS), so a long
45
+ // list doesn't queue hundreds of requests ahead of the images the app is
46
+ // showing (Glide's executors run requests in order within a priority).
47
+ private static final int PRELOAD_LIMIT = 3;
48
+ // Preloads waiting to start, in the order they were added, and the number
49
+ // loading. Only used on the UI thread (Glide calls its listeners there).
50
+ private static final ArrayDeque<Runnable> pendingPreloads = new ArrayDeque<>();
51
+ private static int preloadsInFlight = 0;
52
+
53
+ // Starts pending preloads while there's room. A listener calls it again,
54
+ // sometimes from inside run() (Glide reports a memory-cached image
55
+ // synchronously from preload()). That's fine: the counters are shared and
56
+ // updated before each run(), so the inner call starts what fits, and the
57
+ // outer loop sees the updated counters when it checks again.
58
+ private static void startPendingPreloads() {
59
+ while (preloadsInFlight < PRELOAD_LIMIT && !pendingPreloads.isEmpty()) {
60
+ preloadsInFlight++;
61
+ pendingPreloads.poll().run();
62
+ }
63
+ }
64
+
65
+ // Resolves with a result per source, in order, once all have loaded or
66
+ // failed: { ok, width, height } or { ok: false, error }. Never rejects.
33
67
  @ReactMethod
34
- public void preload(final ReadableArray sources) {
35
- final Activity activity = getCurrentActivity();
36
- if (activity == null) return;
37
- activity.runOnUiThread(new Runnable() {
68
+ public void preload(final ReadableArray sources, final Promise promise) {
69
+ final ReactApplicationContext context = getReactApplicationContext();
70
+ UiThreadUtil.runOnUiThread(new Runnable() {
38
71
  @Override
39
72
  public void run() {
40
- for (int i = 0; i < sources.size(); i++) {
41
- final ReadableMap source = sources.getMap(i);
42
- // Skip sources without a uri (Glide throws on an empty url).
43
- // preload has no way to report errors, so log them.
73
+ final int count = sources.size();
74
+ final WritableMap[] results = new WritableMap[count];
75
+ // Sources of this call still to finish. It starts at the
76
+ // number of sources, so the promise can't resolve before
77
+ // they've all been added.
78
+ final int[] remaining = {count};
79
+ final Runnable finishOne = new Runnable() {
80
+ @Override
81
+ public void run() {
82
+ if (--remaining[0] > 0) return;
83
+ WritableArray array = Arguments.createArray();
84
+ for (WritableMap result : results) array.pushMap(result);
85
+ promise.resolve(array);
86
+ }
87
+ };
88
+ for (int i = 0; i < count; i++) {
89
+ final int index = i;
90
+ final ReadableMap source = sources.isNull(i) ? null : sources.getMap(i);
91
+ // Glide throws on an empty url. Invalid sources fail
92
+ // without taking a slot.
44
93
  if (!FastImageViewConverter.hasUri(source)) {
45
- Log.w(TAG, "preload: skipping a source without a uri");
94
+ results[i] = failure("Invalid source: no uri");
95
+ finishOne.run();
46
96
  continue;
47
97
  }
48
- final FastImageSource imageSource = FastImageViewConverter.getImageSource(activity, source);
49
- // Also skip a uri that can't be resolved (e.g. a relative path),
50
- // which resolves to an empty one. The view reports it as an error.
98
+ final FastImageSource imageSource = FastImageViewConverter.getImageSource(context, source);
99
+ // A uri that can't be resolved (e.g. a relative path) resolves
100
+ // to an empty one.
51
101
  if (imageSource.getUri().toString().isEmpty()) {
52
- Log.w(TAG, "preload: skipping a uri that can't be resolved: " + source.getString("uri"));
102
+ results[i] = failure("Invalid source: can't resolve " + source.getString("uri"));
103
+ finishOne.run();
53
104
  continue;
54
105
  }
106
+ RequestOptions options = FastImageViewConverter.getOptions(context, imageSource, source);
107
+ // Low priority unless the source sets one, as on iOS (the
108
+ // prefetcher's options), so the images the app shows load
109
+ // first.
110
+ final RequestOptions preloadOptions = source.hasKey("priority") && !source.isNull("priority")
111
+ ? options
112
+ : options.priority(Priority.LOW);
113
+ pendingPreloads.add(new Runnable() {
114
+ @Override
115
+ public void run() {
116
+ Glide
117
+ .with(context)
118
+ // Load it the way the view does, so local images
119
+ // (file://, content://, asset:/) work too.
120
+ .load(imageSource.getSourceForLoad())
121
+ .apply(preloadOptions)
122
+ .listener(new RequestListener<Drawable>() {
123
+ @Override
124
+ public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
125
+ results[index] = failure(errorMessage(e));
126
+ preloadsInFlight--;
127
+ finishOne.run();
128
+ startPendingPreloads();
129
+ return false;
130
+ }
55
131
 
56
- Glide
57
- .with(activity.getApplicationContext())
58
- // Load it the way the view does, so local images
59
- // (file://, content://, asset:/) work too.
60
- .load(imageSource.getSourceForLoad())
61
- .apply(FastImageViewConverter.getOptions(activity, imageSource, source))
62
- .preload();
132
+ @Override
133
+ public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
134
+ // Preloaded at its original size, so this is
135
+ // the image's own size.
136
+ WritableMap result = Arguments.createMap();
137
+ result.putBoolean("ok", true);
138
+ result.putInt("width", resource.getIntrinsicWidth());
139
+ result.putInt("height", resource.getIntrinsicHeight());
140
+ results[index] = result;
141
+ preloadsInFlight--;
142
+ finishOne.run();
143
+ startPendingPreloads();
144
+ return false;
145
+ }
146
+ })
147
+ .preload();
148
+ }
149
+ });
63
150
  }
151
+ if (count == 0) {
152
+ promise.resolve(Arguments.createArray());
153
+ return;
154
+ }
155
+ startPendingPreloads();
64
156
  }
65
157
  });
66
158
  }
67
159
 
160
+ private static WritableMap failure(String error) {
161
+ WritableMap result = Arguments.createMap();
162
+ result.putBoolean("ok", false);
163
+ result.putString("error", error);
164
+ return result;
165
+ }
166
+
167
+ // The first root cause's message, e.g. "Not Found, status code: 404".
168
+ private static String errorMessage(@Nullable GlideException e) {
169
+ if (e != null) {
170
+ for (Throwable cause : e.getRootCauses()) {
171
+ if (cause.getMessage() != null) return cause.getMessage();
172
+ }
173
+ if (e.getMessage() != null) return e.getMessage();
174
+ }
175
+ return "Failed to load the image";
176
+ }
177
+
68
178
  @ReactMethod
69
179
  public void clearMemoryCache(final Promise promise) {
70
180
  final Activity activity = getCurrentActivity();
@@ -15,6 +15,8 @@ import com.bumptech.glide.RequestBuilder;
15
15
  import com.bumptech.glide.RequestManager;
16
16
  import com.bumptech.glide.load.model.GlideUrl;
17
17
  import com.bumptech.glide.request.Request;
18
+ import com.bumptech.glide.request.target.DrawableImageViewTarget;
19
+ import com.bumptech.glide.request.target.SizeReadyCallback;
18
20
  import com.facebook.react.bridge.ReadableMap;
19
21
  import com.facebook.react.bridge.WritableMap;
20
22
  import com.facebook.react.bridge.WritableNativeMap;
@@ -67,6 +69,143 @@ class FastImageViewWithUrl extends AppCompatImageView {
67
69
  mNeedsReload = true;
68
70
  }
69
71
 
72
+ // The request for the image the view shows once it has loaded, and the one
73
+ // loading. A new source starts with the shown one as a thumbnail, from the
74
+ // cache only, so the image stays until the new one has loaded instead of
75
+ // flashing blank (#747). This is Glide's safe way to do that: the previous
76
+ // bitmap can't be kept in the view itself, since Glide reuses it once its
77
+ // request is cleared.
78
+ @Nullable
79
+ private RequestBuilder<Drawable> mShownRequest;
80
+ @Nullable
81
+ private RequestBuilder<Drawable> mLoadingRequest;
82
+ // Counts loads, to tell whether a posted update is for the current one.
83
+ private int mLoadCount = 0;
84
+ // The size Glide loaded the shown image at, and is loading the loading
85
+ // one at (0 until it has one).
86
+ private int mShownWidth = 0;
87
+ private int mShownHeight = 0;
88
+ private int mLoadingWidth = 0;
89
+ private int mLoadingHeight = 0;
90
+ private boolean mResizeReloadPosted = false;
91
+
92
+ @Nullable
93
+ private String mRecyclingKey;
94
+
95
+ // When it changes, the next image doesn't replace the current one: the
96
+ // view clears first (for views reused for other content, like list rows).
97
+ void setRecyclingKey(@Nullable String recyclingKey) {
98
+ if (recyclingKey == null ? mRecyclingKey == null : recyclingKey.equals(mRecyclingKey)) return;
99
+ boolean changed = mRecyclingKey != null;
100
+ mRecyclingKey = recyclingKey;
101
+ if (changed) {
102
+ // No thumbnail of the current image, so Glide clears the view to
103
+ // defaultSource (or nothing) while the next one loads. Reload even
104
+ // if the source is the same.
105
+ mShownRequest = null;
106
+ mNeedsReload = true;
107
+ }
108
+ }
109
+
110
+ // The loading image loaded (FastImageRequestListener).
111
+ void onImageLoaded() {
112
+ mShownRequest = mLoadingRequest;
113
+ mShownWidth = mLoadingWidth;
114
+ mShownHeight = mLoadingHeight;
115
+ reloadIfResized();
116
+ }
117
+
118
+ // Glide crops or scales the image to the view's size when it loads, so if
119
+ // the view's size changes after that, load it again at the new size.
120
+ // Otherwise a view that gets taller shows a zoomed-in slice of it (#983).
121
+ @Override
122
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
123
+ super.onSizeChanged(w, h, oldw, oldh);
124
+ reloadIfResized();
125
+ }
126
+
127
+ private void reloadIfResized() {
128
+ if (mResizeReloadPosted || !isResized()) return;
129
+ mResizeReloadPosted = true;
130
+ // After layout (once per frame), and not from within Glide's callback.
131
+ post(new Runnable() {
132
+ @Override
133
+ public void run() {
134
+ mResizeReloadPosted = false;
135
+ if (isResized()) reloadForSize();
136
+ }
137
+ });
138
+ }
139
+
140
+ // Whether the image loaded, at a size the view doesn't have now.
141
+ private boolean isResized() {
142
+ int width = getWidth() - getPaddingLeft() - getPaddingRight();
143
+ int height = getHeight() - getPaddingTop() - getPaddingBottom();
144
+ return requestManager != null && !mNeedsReload
145
+ && mShownRequest != null && mShownRequest == mLoadingRequest
146
+ && mShownWidth > 0 && mShownHeight > 0 && width > 0 && height > 0
147
+ && (width != mShownWidth || height != mShownHeight);
148
+ }
149
+
150
+ // The same image at the view's new size. Meanwhile, and if that fails, the
151
+ // view shows the image at its old size, from the cache. It's the image
152
+ // that's already showing, so this sends no events.
153
+ @SuppressLint("CheckResult")
154
+ private void reloadForSize() {
155
+ RequestBuilder<Drawable> shown = mShownRequest;
156
+ RequestBuilder<Drawable> current = fromCache(shown);
157
+ mLoadCount++;
158
+ clearView(requestManager);
159
+ into(shown, shown.clone()
160
+ .thumbnail(current)
161
+ .error(current.clone())
162
+ .listener(new FastImageRequestListener(null, null, true, false)));
163
+ }
164
+
165
+ // The shown image, from the cache only (never loaded again), at the size
166
+ // it was loaded at: the key it's in Glide's memory cache under.
167
+ @SuppressLint("CheckResult")
168
+ private RequestBuilder<Drawable> fromCache(RequestBuilder<Drawable> shown) {
169
+ RequestBuilder<Drawable> request = shown.clone().onlyRetrieveFromCache(true);
170
+ if (mShownWidth > 0 && mShownHeight > 0) request = request.override(mShownWidth, mShownHeight);
171
+ return request;
172
+ }
173
+
174
+ // Starts loading the request (built by builder), recording the size Glide
175
+ // loads it at. The size callback is added first, so it has the size by
176
+ // the time the image loads, even from the memory cache.
177
+ private void into(RequestBuilder<Drawable> request, RequestBuilder<Drawable> builder) {
178
+ final int load = mLoadCount;
179
+ mLoadingRequest = request;
180
+ mLoadingWidth = 0;
181
+ mLoadingHeight = 0;
182
+ DrawableImageViewTarget target = new DrawableImageViewTarget(this);
183
+ target.getSize(new SizeReadyCallback() {
184
+ @Override
185
+ public void onSizeReady(int width, int height) {
186
+ if (load != mLoadCount) return;
187
+ mLoadingWidth = width;
188
+ mLoadingHeight = height;
189
+ }
190
+ });
191
+ builder.into(target);
192
+ }
193
+
194
+ // The loading image failed (FastImageRequestListener). Glide shows
195
+ // defaultSource then, but not over a thumbnail: show it here, as iOS does.
196
+ void onImageFailed(boolean hadThumbnail) {
197
+ mShownRequest = null;
198
+ if (!hadThumbnail) return;
199
+ final int load = mLoadCount;
200
+ // Not from within Glide's callback.
201
+ post(new Runnable() {
202
+ @Override
203
+ public void run() {
204
+ if (load == mLoadCount) setImageDrawable(mDefaultSource);
205
+ }
206
+ });
207
+ }
208
+
70
209
  @SuppressLint("CheckResult")
71
210
  public void onAfterUpdate(
72
211
  @Nonnull FastImageViewManager manager,
@@ -77,6 +216,10 @@ class FastImageViewWithUrl extends AppCompatImageView {
77
216
  // Only reload for changes that affect the request (source,
78
217
  // defaultSource, resizeMode), not for every prop update.
79
218
  mNeedsReload = false;
219
+ mLoadCount++;
220
+ RequestBuilder<Drawable> shownRequest = mShownRequest;
221
+ mShownRequest = null;
222
+ mLoadingRequest = null;
80
223
 
81
224
  // Nothing to show.
82
225
  if (mSource == null && mDefaultSource == null) {
@@ -162,11 +305,17 @@ class FastImageViewWithUrl extends AppCompatImageView {
162
305
  // What into() would apply for the scale type, with
163
306
  // the size capture.
164
307
  .apply(FastImageSourceSize.scaleTypeOptions(getScaleType(), capture));
308
+ RequestBuilder<Drawable> request = builder.clone();
309
+
310
+ boolean thumbnail = shownRequest != null && model != null;
311
+ if (thumbnail) {
312
+ builder = builder.thumbnail(fromCache(shownRequest));
313
+ }
165
314
 
166
315
  if (key != null)
167
- builder.listener(new FastImageRequestListener(key, imageSource));
316
+ builder.listener(new FastImageRequestListener(key, imageSource, thumbnail, true));
168
317
 
169
- builder.into(this);
318
+ into(request, builder);
170
319
  }
171
320
  }
172
321
 
package/dist/index.cjs.js CHANGED
@@ -129,7 +129,16 @@ const FastImage = FastImageComponent;
129
129
  FastImage.resizeMode = resizeMode;
130
130
  FastImage.cacheControl = cacheControl;
131
131
  FastImage.priority = priority;
132
- FastImage.preload = (sources) => react_native.NativeModules.FastImageView.preload(sources);
132
+ FastImage.preload = (sources) => Promise.resolve(react_native.NativeModules.FastImageView.preload(sources.map((s) => s || {}))).then((results) => sources.map((source, i) => {
133
+ var _results$i;
134
+ return {
135
+ uri: source ? source.uri : void 0,
136
+ ...(_results$i = results === null || results === void 0 ? void 0 : results[i]) !== null && _results$i !== void 0 ? _results$i : {
137
+ ok: false,
138
+ error: "No result"
139
+ }
140
+ };
141
+ }));
133
142
  FastImage.clearMemoryCache = () => react_native.NativeModules.FastImageView.clearMemoryCache();
134
143
  FastImage.clearDiskCache = () => react_native.NativeModules.FastImageView.clearDiskCache();
135
144
  const styles = react_native.StyleSheet.create({
@@ -43,7 +43,17 @@ export type ResizeModes = $Values<ResizeMode>
43
43
  export type Priorities = $Values<Priority>
44
44
  export type CacheControls = $Values<CacheControl>
45
45
 
46
- export type PreloadFn = (sources: Array<FastImageSource>) => void
46
+ export type PreloadResult = {|
47
+ uri?: string,
48
+ ok: boolean,
49
+ width?: number,
50
+ height?: number,
51
+ error?: string,
52
+ |}
53
+
54
+ export type PreloadFn = (
55
+ sources: Array<FastImageSource>,
56
+ ) => Promise<Array<PreloadResult>>
47
57
  export type FastImageSource = {
48
58
  uri?: string,
49
59
  headers?: Object,
@@ -65,6 +75,7 @@ export type FastImageProps = $ReadOnly<{|
65
75
  tintColor?: number | string,
66
76
  resizeMode?: ?ResizeModes,
67
77
  fallback?: ?boolean,
78
+ recyclingKey?: ?string,
68
79
  testID?: ?string,
69
80
  |}>
70
81
 
package/dist/index.d.ts CHANGED
@@ -60,6 +60,15 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
60
60
  defaultSource?: ImageRequireSource;
61
61
  resizeMode?: ResizeMode;
62
62
  fallback?: boolean;
63
+ /**
64
+ * When `source` changes, the image showing stays until the new one has
65
+ * loaded. For views that get reused for other content, such as rows in
66
+ * FlashList or recyclerlistview, set this to something that identifies the
67
+ * content (e.g. the item's id): when it changes, the image is cleared
68
+ * right away instead, so a reused row doesn't show the previous row's
69
+ * image. Unlike changing `key`, the view is kept.
70
+ */
71
+ recyclingKey?: string | null;
63
72
  onLoadStart?(): void;
64
73
  onProgress?(event: OnProgressEvent): void;
65
74
  onLoad?(event: OnLoadEvent): void;
@@ -93,11 +102,18 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
93
102
  */
94
103
  children?: React.ReactNode;
95
104
  }
105
+ export interface PreloadResult {
106
+ uri?: string;
107
+ ok: boolean;
108
+ width?: number;
109
+ height?: number;
110
+ error?: string;
111
+ }
96
112
  export interface FastImageStaticProperties {
97
113
  resizeMode: typeof resizeMode;
98
114
  priority: typeof priority;
99
115
  cacheControl: typeof cacheControl;
100
- preload: (sources: Source[]) => void;
116
+ preload: (sources: Source[]) => Promise<PreloadResult[]>;
101
117
  clearMemoryCache: () => Promise<void>;
102
118
  clearDiskCache: () => Promise<void>;
103
119
  }
package/dist/index.js CHANGED
@@ -106,7 +106,16 @@ const FastImage = FastImageComponent;
106
106
  FastImage.resizeMode = resizeMode;
107
107
  FastImage.cacheControl = cacheControl;
108
108
  FastImage.priority = priority;
109
- FastImage.preload = (sources) => NativeModules.FastImageView.preload(sources);
109
+ FastImage.preload = (sources) => Promise.resolve(NativeModules.FastImageView.preload(sources.map((s) => s || {}))).then((results) => sources.map((source, i) => {
110
+ var _results$i;
111
+ return {
112
+ uri: source ? source.uri : void 0,
113
+ ...(_results$i = results === null || results === void 0 ? void 0 : results[i]) !== null && _results$i !== void 0 ? _results$i : {
114
+ ok: false,
115
+ error: "No result"
116
+ }
117
+ };
118
+ }));
110
119
  FastImage.clearMemoryCache = () => NativeModules.FastImageView.clearMemoryCache();
111
120
  FastImage.clearDiskCache = () => NativeModules.FastImageView.clearDiskCache();
112
121
  const styles = StyleSheet.create({
@@ -43,7 +43,17 @@ export type ResizeModes = $Values<ResizeMode>
43
43
  export type Priorities = $Values<Priority>
44
44
  export type CacheControls = $Values<CacheControl>
45
45
 
46
- export type PreloadFn = (sources: Array<FastImageSource>) => void
46
+ export type PreloadResult = {|
47
+ uri?: string,
48
+ ok: boolean,
49
+ width?: number,
50
+ height?: number,
51
+ error?: string,
52
+ |}
53
+
54
+ export type PreloadFn = (
55
+ sources: Array<FastImageSource>,
56
+ ) => Promise<Array<PreloadResult>>
47
57
  export type FastImageSource = {
48
58
  uri?: string,
49
59
  headers?: Object,
@@ -65,6 +75,7 @@ export type FastImageProps = $ReadOnly<{|
65
75
  tintColor?: number | string,
66
76
  resizeMode?: ?ResizeModes,
67
77
  fallback?: ?boolean,
78
+ recyclingKey?: ?string,
68
79
  testID?: ?string,
69
80
  |}>
70
81
 
@@ -21,6 +21,8 @@ typedef NS_ENUM(NSInteger, FFFCacheControl) {
21
21
  @property (nonatomic) NSURL* url;
22
22
  // priority for image request
23
23
  @property (nonatomic) FFFPriority priority;
24
+ // whether the source set a priority (preload is low priority otherwise)
25
+ @property (nonatomic) BOOL hasPriority;
24
26
  // headers for the image request
25
27
  @property (nonatomic) NSDictionary *headers;
26
28
  // cache control mode
@@ -19,6 +19,9 @@
19
19
  @property (nonatomic, strong) FFFastImageSource *source;
20
20
  @property (nonatomic, strong) UIImage *defaultSource;
21
21
  @property (nonatomic, strong) UIColor *imageColor;
22
+ // When it changes, the next image doesn't replace the current one: the view
23
+ // clears first (for views reused for other content, like list rows).
24
+ @property (nonatomic, copy) NSString *recyclingKey;
22
25
 
23
26
  @end
24
27
 
@@ -20,6 +20,9 @@
20
20
  @property(nonatomic, assign) BOOL retriedAfterBackground;
21
21
  // Waits for the app to be active again, to restart a load.
22
22
  @property(nonatomic, strong) id activeObserver;
23
+ // Whether the view shows an image that loaded (not defaultSource or nothing).
24
+ // A new source then keeps it until the new image has loaded (see reloadImage).
25
+ @property(nonatomic, assign) BOOL showsLoadedImage;
23
26
 
24
27
  @end
25
28
 
@@ -186,6 +189,21 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
186
189
  }
187
190
  }
188
191
 
192
+ - (void) setRecyclingKey: (NSString*)recyclingKey {
193
+ if (_recyclingKey == recyclingKey || [_recyclingKey isEqualToString: recyclingKey]) {
194
+ return;
195
+ }
196
+ BOOL changed = _recyclingKey != nil;
197
+ _recyclingKey = [recyclingKey copy];
198
+ if (changed) {
199
+ // The view shows other content now: don't keep the current image
200
+ // while the next one loads (reloadImage clears it), even if the
201
+ // source is the same.
202
+ self.showsLoadedImage = NO;
203
+ _needsReload = YES;
204
+ }
205
+ }
206
+
189
207
  - (void) setDefaultSource: (UIImage*)defaultSource {
190
208
  if (_defaultSource != defaultSource) {
191
209
  _defaultSource = defaultSource;
@@ -217,6 +235,7 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
217
235
  if (!image) {
218
236
  // Not decodable: fail like a remote image, showing defaultSource.
219
237
  [self setImage: _defaultSource];
238
+ self.showsLoadedImage = NO;
220
239
  self.hasErrored = YES;
221
240
  if (self.onFastImageError) {
222
241
  self.onFastImageError(@{});
@@ -234,6 +253,7 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
234
253
  });
235
254
  }
236
255
  self.hasCompleted = YES;
256
+ self.showsLoadedImage = YES;
237
257
  [self sendOnLoad: image];
238
258
 
239
259
  if (self.onFastImageLoadEnd) {
@@ -270,6 +290,14 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
270
290
  break;
271
291
  }
272
292
 
293
+ // Keep showing the loaded image until the new one has loaded, instead
294
+ // of clearing it to defaultSource (or nothing) while it loads, which
295
+ // flashed (#747). defaultSource shows if the new image fails. As React
296
+ // Native's Image does; a `key` that changes starts from blank instead.
297
+ if (self.showsLoadedImage) {
298
+ options |= SDWebImageDelayPlaceholder;
299
+ }
300
+
273
301
  if (self.onFastImageLoadStart) {
274
302
  self.onFastImageLoadStart(@{});
275
303
  self.hasSentOnLoadStart = YES;
@@ -284,6 +312,7 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
284
312
  [self downloadImage: _source options: options context: context];
285
313
  } else if (_defaultSource) {
286
314
  [self setImage: _defaultSource];
315
+ self.showsLoadedImage = NO;
287
316
  }
288
317
  }
289
318
 
@@ -338,6 +367,8 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
338
367
  return;
339
368
  }
340
369
  if (error) {
370
+ // SDWebImage shows the placeholder (defaultSource or nothing).
371
+ weakSelf.showsLoadedImage = NO;
341
372
  weakSelf.hasErrored = YES;
342
373
  if (weakSelf.onFastImageError) {
343
374
  weakSelf.onFastImageError(@{});
@@ -347,6 +378,7 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
347
378
  }
348
379
  } else {
349
380
  weakSelf.hasCompleted = YES;
381
+ weakSelf.showsLoadedImage = YES;
350
382
  [weakSelf sendOnLoad: image];
351
383
  if (weakSelf.onFastImageLoadEnd) {
352
384
  weakSelf.onFastImageLoadEnd(@{});
@@ -1,9 +1,9 @@
1
1
  #import "FFFastImageViewManager.h"
2
2
  #import "FFFastImageView.h"
3
- #import <React/RCTLog.h>
4
3
 
5
4
  #import <SDWebImage/SDImageCache.h>
6
5
  #import <SDWebImage/SDWebImageManager.h>
6
+ #import <SDWebImage/SDWebImageError.h>
7
7
  #import <SDWebImage/SDWebImagePrefetcher.h>
8
8
 
9
9
  @implementation FFFastImageViewManager
@@ -17,6 +17,7 @@ RCT_EXPORT_MODULE(FastImageView)
17
17
  RCT_EXPORT_VIEW_PROPERTY(source, FFFastImageSource)
18
18
  RCT_EXPORT_VIEW_PROPERTY(defaultSource, UIImage)
19
19
  RCT_EXPORT_VIEW_PROPERTY(resizeMode, RCTResizeMode)
20
+ RCT_EXPORT_VIEW_PROPERTY(recyclingKey, NSString)
20
21
  RCT_EXPORT_VIEW_PROPERTY(onFastImageLoadStart, RCTDirectEventBlock)
21
22
  RCT_EXPORT_VIEW_PROPERTY(onFastImageProgress, RCTDirectEventBlock)
22
23
  RCT_EXPORT_VIEW_PROPERTY(onFastImageError, RCTDirectEventBlock)
@@ -24,36 +25,119 @@ RCT_EXPORT_VIEW_PROPERTY(onFastImageLoad, RCTDirectEventBlock)
24
25
  RCT_EXPORT_VIEW_PROPERTY(onFastImageLoadEnd, RCTDirectEventBlock)
25
26
  RCT_REMAP_VIEW_PROPERTY(tintColor, imageColor, UIColor)
26
27
 
27
- RCT_EXPORT_METHOD(preload:(nonnull NSArray<FFFastImageSource *> *)sources)
28
+ // The error's description, with the HTTP status code when there is one.
29
+ static NSString *FFFErrorMessage(NSError *error)
28
30
  {
29
- SDWebImagePrefetcher *prefetcher = [SDWebImagePrefetcher sharedImagePrefetcher];
30
- NSMutableArray *urls = [NSMutableArray arrayWithCapacity:sources.count];
31
+ NSNumber *statusCode = error.userInfo[SDWebImageErrorDownloadStatusCodeKey];
32
+ if (statusCode) {
33
+ return [NSString stringWithFormat:@"%@, status code: %@", error.localizedDescription, statusCode];
34
+ }
35
+ return error.localizedDescription ?: @"Failed to load the image";
36
+ }
31
37
 
32
- [sources enumerateObjectsUsingBlock:^(FFFastImageSource * _Nonnull source, NSUInteger idx, BOOL * _Nonnull stop) {
33
- // Skip sources without a url (an empty, missing or null uri). NSArray can't
34
- // hold nil, so adding one throws and crashes the app.
35
- if (!source.url) {
36
- // preload has no way to report errors, so log it.
37
- RCTLogWarn(@"FastImage.preload: skipping a source without a valid uri");
38
- return;
38
+ // Preloads waiting to start, in the order they were added, and the number
39
+ // loading, across all preload calls. Like SDWebImagePrefetcher, at most
40
+ // maxConcurrentPrefetchCount load at a time, so a long list doesn't queue
41
+ // hundreds of operations ahead of the images the app is showing. Only used on
42
+ // the main queue (SDWebImageManager calls its completion blocks there).
43
+ static NSMutableArray<dispatch_block_t> *FFFPendingPreloads;
44
+ static NSUInteger FFFPreloadsInFlight;
45
+
46
+ static NSUInteger FFFPreloadLimit(void)
47
+ {
48
+ NSUInteger limit = [SDWebImagePrefetcher sharedImagePrefetcher].maxConcurrentPrefetchCount;
49
+ return MAX(limit, (NSUInteger)1);
50
+ }
51
+
52
+ // Starts pending preloads while there's room. A completion block calls it
53
+ // again, sometimes from inside start() (a memory cache hit completes
54
+ // synchronously on the main queue). That's fine: the counters are shared and
55
+ // updated before each start(), so the inner call starts what fits, and the
56
+ // outer loop sees the updated counters when it checks again.
57
+ static void FFFStartPendingPreloads(void)
58
+ {
59
+ while (FFFPreloadsInFlight < FFFPreloadLimit() && FFFPendingPreloads.count > 0) {
60
+ dispatch_block_t start = FFFPendingPreloads.firstObject;
61
+ [FFFPendingPreloads removeObjectAtIndex:0];
62
+ FFFPreloadsInFlight++;
63
+ start();
64
+ }
65
+ }
66
+
67
+ // Resolves with a result per source, in order, once all have loaded or failed:
68
+ // { ok, width, height } or { ok: false, error }. Never rejects.
69
+ RCT_EXPORT_METHOD(preload:(nonnull NSArray<FFFastImageSource *> *)sources
70
+ resolve:(RCTPromiseResolveBlock)resolve
71
+ reject:(__unused RCTPromiseRejectBlock)reject)
72
+ {
73
+ // This runs on the UIManager queue; the queue state is only touched on
74
+ // the main queue.
75
+ dispatch_async(dispatch_get_main_queue(), ^{
76
+ if (!FFFPendingPreloads) {
77
+ FFFPendingPreloads = [NSMutableArray array];
39
78
  }
40
- if (source.headers.count == 0) {
41
- [urls addObject:source.url];
79
+ NSMutableArray *results = [NSMutableArray arrayWithCapacity:sources.count];
80
+ // Sources of this call still to finish. It starts at the number of
81
+ // sources, so the promise can't resolve before they've all been added.
82
+ __block NSUInteger remaining = sources.count;
83
+ void (^finishOne)(void) = ^{
84
+ if (--remaining == 0) {
85
+ resolve(results);
86
+ }
87
+ };
88
+ // With the prefetcher's options (low priority), but one source at a
89
+ // time (not SDWebImagePrefetcher), to get each source's result and to
90
+ // send its headers with its own request only.
91
+ SDWebImageOptions prefetcherOptions = [SDWebImagePrefetcher sharedImagePrefetcher].options;
92
+
93
+ [sources enumerateObjectsUsingBlock:^(FFFastImageSource * _Nonnull source, NSUInteger idx, BOOL * _Nonnull stop) {
94
+ if (!source.url) {
95
+ // An empty, missing or null uri (JS sends null sources as {}).
96
+ // It fails without taking a slot.
97
+ [results addObject:@{@"ok": @NO, @"error": @"Invalid source: no uri"}];
98
+ finishOne();
99
+ return;
100
+ }
101
+ [results addObject:[NSNull null]];
102
+ // A source's own priority replaces the prefetcher's, as on Android.
103
+ SDWebImageOptions options = prefetcherOptions;
104
+ if (source.hasPriority) {
105
+ options &= ~(SDWebImageLowPriority | SDWebImageHighPriority);
106
+ if (source.priority == FFFPriorityLow) {
107
+ options |= SDWebImageLowPriority;
108
+ } else if (source.priority == FFFPriorityHigh) {
109
+ options |= SDWebImageHighPriority;
110
+ }
111
+ }
112
+ [FFFPendingPreloads addObject:[^{
113
+ // Once per slot: SDWebImage calls this once with finished set
114
+ // (a progressive load calls it more, without).
115
+ __block BOOL done = NO;
116
+ [[SDWebImageManager sharedManager] loadImageWithURL:source.url
117
+ options:options
118
+ context:@{SDWebImageContextDownloadRequestModifier: source.requestModifier}
119
+ progress:nil
120
+ completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
121
+ if (!finished || done) {
122
+ return;
123
+ }
124
+ done = YES;
125
+ results[idx] = image
126
+ ? @{@"ok": @YES, @"width": @(image.size.width), @"height": @(image.size.height)}
127
+ : @{@"ok": @NO, @"error": FFFErrorMessage(error)};
128
+ FFFPreloadsInFlight--;
129
+ finishOne();
130
+ FFFStartPendingPreloads();
131
+ }];
132
+ } copy]];
133
+ }];
134
+
135
+ if (sources.count == 0) {
136
+ resolve(results);
42
137
  return;
43
138
  }
44
- // Send the headers with this source's request only (setting them on
45
- // the shared downloader sent them with every later request). The
46
- // prefetcher can't take headers per request in SDWebImage 5.11 (its
47
- // context is shared by all prefetches), so load it the way the
48
- // prefetcher does, with the same options.
49
- [[SDWebImageManager sharedManager] loadImageWithURL:source.url
50
- options:prefetcher.options
51
- context:@{SDWebImageContextDownloadRequestModifier: source.requestModifier}
52
- progress:nil
53
- completed:^(UIImage *image, NSData *data, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {}];
54
- }];
55
-
56
- [prefetcher prefetchURLs:urls];
139
+ FFFStartPendingPreloads();
140
+ });
57
141
  }
58
142
 
59
143
  RCT_EXPORT_METHOD(clearMemoryCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
@@ -45,6 +45,8 @@ RCT_ENUM_CONVERTER(FFFCacheControl, (@{
45
45
  }
46
46
 
47
47
  FFFastImageSource *imageSource = [[FFFastImageSource alloc] initWithURL:uri priority:priority headers:headers cacheControl:cacheControl];
48
+ id priorityJSON = json[@"priority"];
49
+ imageSource.hasPriority = priorityJSON && priorityJSON != (id)kCFNull;
48
50
 
49
51
  return imageSource;
50
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-fast-image",
3
- "version": "8.6.43",
3
+ "version": "8.8.0",
4
4
  "description": "🚩 FastImage, performant React Native image component.",
5
5
  "keywords": [
6
6
  "cache",