react-native-fast-image 8.20.0 → 8.21.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
@@ -222,6 +222,22 @@ Decoding a smaller copy can take a little longer, so use it where the memory mat
222
222
 
223
223
  ---
224
224
 
225
+ ### `blurRadius?: number`
226
+
227
+ Blurs the image by this radius, in points, like React Native's `Image` (the same radius looks about the same on iOS and Android). `0` **(Default)** is no blur.
228
+
229
+ It's for still images, or a radius that changes now and then (e.g. blurring a photo behind a sheet). Each change blurs the image again on the CPU, so don't animate it.
230
+
231
+ - Only the loaded image is blurred, not `defaultSource`.
232
+ - An animated image (GIF, animated WebP) shows its first frame, blurred, and doesn't animate.
233
+ - With `tintColor`, the blurred image is tinted.
234
+ - The image is blurred at about the size it's shown at, off the main thread. The cached file stays the original image, so `getCachePath` and other views of it aren't affected.
235
+ - Changing it blurs the image that's showing again, without sending the load events again.
236
+
237
+ To animate a blur, or to blur an animated image, use React Native's `filter` style instead, which the GPU draws: `style={{ filter: [{ blur: 6 }] }}`. It needs the New Architecture. React Native's docs list `blur` for Android 12+ only; on iOS it's behind an experimental React Native feature flag (`enableSwiftUIBasedFilters`, SwiftUI-based filters).
238
+
239
+ ---
240
+
225
241
  ### `onLoadStart?: () => void`
226
242
 
227
243
  Called when the image starts to load.
@@ -0,0 +1,213 @@
1
+ package com.dylanvann.fastimage;
2
+
3
+ import android.graphics.Bitmap;
4
+ import android.graphics.Canvas;
5
+ import android.graphics.Paint;
6
+ import android.graphics.PorterDuff;
7
+ import android.graphics.PorterDuffXfermode;
8
+ import android.widget.ImageView;
9
+
10
+ import androidx.annotation.NonNull;
11
+
12
+ import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
13
+ import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
14
+ import com.bumptech.glide.request.target.Target;
15
+
16
+ import java.nio.IntBuffer;
17
+ import java.nio.charset.Charset;
18
+ import java.security.MessageDigest;
19
+
20
+ // Blurs the image (blurRadius), as React Native's Image does on iOS: three box
21
+ // blurs, close to a Gaussian blur, with the box size worked out from the radius
22
+ // (in pixels) as the SVG spec describes and halved, as React Native does. So a
23
+ // radius looks the same on both platforms. It runs after the scale type's crop
24
+ // or fit, on the image at about the size the view shows it (a blurred image
25
+ // needs no more detail), on Glide's threads. Part of the request's key, so
26
+ // blurred and unblurred views of one image don't share a cached image; the
27
+ // cached file stays the original.
28
+ final class FastImageBlur extends BitmapTransformation {
29
+ private static final String ID = "com.dylanvann.fastimage.FastImageBlur";
30
+ private static final byte[] ID_BYTES = ID.getBytes(Charset.forName("UTF-8"));
31
+
32
+ // In pixels on screen.
33
+ private final float radius;
34
+ private final ImageView.ScaleType scaleType;
35
+
36
+ FastImageBlur(float radius, ImageView.ScaleType scaleType) {
37
+ this.radius = radius;
38
+ this.scaleType = scaleType;
39
+ }
40
+
41
+ @Override
42
+ protected Bitmap transform(@NonNull BitmapPool pool, @NonNull Bitmap toTransform, int outWidth, int outHeight) {
43
+ int width = toTransform.getWidth();
44
+ int height = toTransform.getHeight();
45
+ if (outWidth == Target.SIZE_ORIGINAL) outWidth = width;
46
+ if (outHeight == Target.SIZE_ORIGINAL) outHeight = height;
47
+ // How many pixels on screen a pixel of the bitmap takes.
48
+ float shownScale = shownScale(width, height, outWidth, outHeight);
49
+ // Blurred at the size it's shown at, or at its own size if it's shown
50
+ // larger.
51
+ float scale = Math.min(shownScale, 1);
52
+ int blurredWidth = Math.max(1, Math.round(width * scale));
53
+ int blurredHeight = Math.max(1, Math.round(height * scale));
54
+ int boxSize = boxSize(radius * scale / shownScale, Math.max(blurredWidth, blurredHeight));
55
+ if (boxSize <= 1 && scale == 1) return toTransform;
56
+
57
+ Bitmap result = pool.get(blurredWidth, blurredHeight, Bitmap.Config.ARGB_8888);
58
+ result.setDensity(toTransform.getDensity());
59
+ Canvas canvas = new Canvas(result);
60
+ canvas.scale(blurredWidth / (float) width, blurredHeight / (float) height);
61
+ canvas.drawBitmap(toTransform, 0, 0, new Paint(Paint.FILTER_BITMAP_FLAG));
62
+ canvas.setBitmap(null);
63
+ if (boxSize > 1) blur(pool, result, boxSize);
64
+ return result;
65
+ }
66
+
67
+ // Pixels on screen per pixel of the bitmap, for the view's scale type.
68
+ private float shownScale(int width, int height, int outWidth, int outHeight) {
69
+ if (width <= 0 || height <= 0 || outWidth <= 0 || outHeight <= 0) return 1;
70
+ float x = outWidth / (float) width;
71
+ float y = outHeight / (float) height;
72
+ if (scaleType == null) return 1;
73
+ switch (scaleType) {
74
+ case CENTER_CROP:
75
+ return Math.max(x, y);
76
+ case FIT_CENTER:
77
+ case FIT_START:
78
+ case FIT_END:
79
+ return Math.min(x, y);
80
+ case FIT_XY:
81
+ return (float) Math.sqrt(x * y);
82
+ case CENTER_INSIDE:
83
+ return Math.min(Math.min(x, y), 1);
84
+ default:
85
+ return 1;
86
+ }
87
+ }
88
+
89
+ // React Native's (RCTImageBlurUtils) for a radius in pixels: odd, so it's
90
+ // centered. At most the image's larger side (a larger box looks the same,
91
+ // and an absurd radius would overflow).
92
+ private static int boxSize(float radius, int max) {
93
+ double size = Math.floor((radius * 3 * Math.sqrt(2 * Math.PI) / 4 + 0.5) / 2);
94
+ return (int) Math.min(size, max) | 1;
95
+ }
96
+
97
+ // Blurs the bitmap, on its premultiplied pixels (as stored), each byte a
98
+ // channel. A large box blurs a copy 2, 4 or 8 times smaller instead (each
99
+ // pixel the average of a block), with a box that much smaller, of at least
100
+ // 11 pixels, drawn back at full size: away from the edges that matches
101
+ // blurring at full size within a few levels of 255 (about as much as a
102
+ // slightly different radius changes it), for a quarter to a sixty-fourth of
103
+ // the work.
104
+ private static void blur(BitmapPool pool, Bitmap bitmap, int boxSize) {
105
+ int width = bitmap.getWidth();
106
+ int height = bitmap.getHeight();
107
+ int[] pixels = new int[width * height];
108
+ IntBuffer buffer = IntBuffer.wrap(pixels);
109
+ bitmap.copyPixelsToBuffer(buffer);
110
+ int factor = boxSize >= 88 ? 8 : boxSize >= 44 ? 4 : boxSize >= 22 ? 2 : 1;
111
+ if (factor == 1) {
112
+ blur(pixels, width, height, boxSize);
113
+ buffer.rewind();
114
+ bitmap.copyPixelsFromBuffer(buffer);
115
+ return;
116
+ }
117
+ int smallWidth = (width + factor - 1) / factor;
118
+ int smallHeight = (height + factor - 1) / factor;
119
+ int[] small = shrink(pixels, width, height, factor, smallWidth, smallHeight);
120
+ blur(small, smallWidth, smallHeight, Math.round((boxSize / (float) factor - 1) / 2) * 2 + 1);
121
+ Bitmap copy = pool.get(smallWidth, smallHeight, Bitmap.Config.ARGB_8888);
122
+ copy.copyPixelsFromBuffer(IntBuffer.wrap(small));
123
+ Canvas canvas = new Canvas(bitmap);
124
+ canvas.scale(factor, factor);
125
+ Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG);
126
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
127
+ canvas.drawBitmap(copy, 0, 0, paint);
128
+ canvas.setBitmap(null);
129
+ pool.put(copy);
130
+ }
131
+
132
+ // Three box blurs, each across then down, with the edges extended.
133
+ private static void blur(int[] pixels, int width, int height, int boxSize) {
134
+ int[] other = new int[width * height];
135
+ int radius = boxSize / 2;
136
+ for (int i = 0; i < 3; i++) {
137
+ for (int y = 0; y < height; y++) boxBlur(pixels, other, y * width, 1, width, radius);
138
+ for (int x = 0; x < width; x++) boxBlur(other, pixels, x, width, height, radius);
139
+ }
140
+ }
141
+
142
+ // A copy factor times smaller: each pixel the average of a factor × factor
143
+ // block (smaller at the right and bottom edges).
144
+ private static int[] shrink(int[] pixels, int width, int height, int factor, int smallWidth, int smallHeight) {
145
+ int[] small = new int[smallWidth * smallHeight];
146
+ for (int sy = 0; sy < smallHeight; sy++) {
147
+ for (int sx = 0; sx < smallWidth; sx++) {
148
+ int a = 0, b = 0, c = 0, d = 0, n = 0;
149
+ for (int y = sy * factor; y < Math.min(height, (sy + 1) * factor); y++) {
150
+ for (int x = sx * factor; x < Math.min(width, (sx + 1) * factor); x++) {
151
+ int pixel = pixels[y * width + x];
152
+ a += pixel >>> 24;
153
+ b += (pixel >> 16) & 0xff;
154
+ c += (pixel >> 8) & 0xff;
155
+ d += pixel & 0xff;
156
+ n++;
157
+ }
158
+ }
159
+ int half = n / 2;
160
+ small[sy * smallWidth + sx] = ((a + half) / n) << 24 | ((b + half) / n) << 16
161
+ | ((c + half) / n) << 8 | ((d + half) / n);
162
+ }
163
+ }
164
+ return small;
165
+ }
166
+
167
+ // Averages each pixel of a row or column (count pixels from start, step
168
+ // apart) with the radius pixels on each side.
169
+ private static void boxBlur(int[] from, int[] to, int start, int step, int count, int radius) {
170
+ int size = 2 * radius + 1;
171
+ int half = size / 2;
172
+ int a = 0, b = 0, c = 0, d = 0;
173
+ for (int i = -radius; i <= radius; i++) {
174
+ int pixel = from[start + clamp(i, count) * step];
175
+ a += pixel >>> 24;
176
+ b += (pixel >> 16) & 0xff;
177
+ c += (pixel >> 8) & 0xff;
178
+ d += pixel & 0xff;
179
+ }
180
+ for (int i = 0; i < count; i++) {
181
+ to[start + i * step] = ((a + half) / size) << 24 | ((b + half) / size) << 16
182
+ | ((c + half) / size) << 8 | ((d + half) / size);
183
+ int added = from[start + clamp(i + radius + 1, count) * step];
184
+ int removed = from[start + clamp(i - radius, count) * step];
185
+ a += (added >>> 24) - (removed >>> 24);
186
+ b += ((added >> 16) & 0xff) - ((removed >> 16) & 0xff);
187
+ c += ((added >> 8) & 0xff) - ((removed >> 8) & 0xff);
188
+ d += (added & 0xff) - (removed & 0xff);
189
+ }
190
+ }
191
+
192
+ private static int clamp(int i, int count) {
193
+ return i < 0 ? 0 : i >= count ? count - 1 : i;
194
+ }
195
+
196
+ @Override
197
+ public boolean equals(Object o) {
198
+ return o instanceof FastImageBlur
199
+ && ((FastImageBlur) o).radius == radius
200
+ && ((FastImageBlur) o).scaleType == scaleType;
201
+ }
202
+
203
+ @Override
204
+ public int hashCode() {
205
+ return ID.hashCode() * 31 + Float.floatToIntBits(radius) * 31 + (scaleType == null ? 0 : scaleType.ordinal());
206
+ }
207
+
208
+ @Override
209
+ public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) {
210
+ messageDigest.update(ID_BYTES);
211
+ messageDigest.update((radius + "," + scaleType).getBytes(Charset.forName("UTF-8")));
212
+ }
213
+ }
@@ -34,7 +34,12 @@ public class FastImageRequestListener implements RequestListener<Drawable> {
34
34
 
35
35
  @Override
36
36
  public boolean onLoadFailed(@androidx.annotation.Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
37
- if (!events) return false;
37
+ if (!events) {
38
+ if (target instanceof ImageViewTarget) {
39
+ ((FastImageViewWithUrl) ((ImageViewTarget) target).getView()).onQuietLoadFailed();
40
+ }
41
+ return false;
42
+ }
38
43
  FastImageOkHttpProgressGlideModule.forget(key);
39
44
  if (!(target instanceof ImageViewTarget)) {
40
45
  return false;
@@ -14,7 +14,12 @@ import androidx.annotation.Nullable;
14
14
 
15
15
  import com.bumptech.glide.Glide;
16
16
  import com.bumptech.glide.load.ImageHeaderParserUtils;
17
+ import com.bumptech.glide.load.MultiTransformation;
18
+ import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
19
+ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
20
+ import com.bumptech.glide.load.resource.bitmap.CenterInside;
17
21
  import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
22
+ import com.bumptech.glide.load.resource.bitmap.FitCenter;
18
23
  import com.bumptech.glide.load.resource.gif.GifDrawable;
19
24
  import com.bumptech.glide.request.RequestOptions;
20
25
 
@@ -82,27 +87,37 @@ final class FastImageSourceSize {
82
87
 
83
88
  // What RequestBuilder.into(ImageView) applies for the view's scale type
84
89
  // (it leaves a transformation that's already set alone), with the
85
- // downsample strategy it implies wrapped by capture.
86
- static RequestOptions scaleTypeOptions(@Nullable ImageView.ScaleType scaleType, Capture capture) {
90
+ // downsample strategy it implies wrapped by capture, and then blur if it
91
+ // isn't null.
92
+ static RequestOptions scaleTypeOptions(@Nullable ImageView.ScaleType scaleType, Capture capture, @Nullable FastImageBlur blur) {
87
93
  RequestOptions options = new RequestOptions();
94
+ BitmapTransformation transformation = null;
88
95
  if (scaleType != null) {
89
96
  switch (scaleType) {
90
97
  case CENTER_CROP:
91
98
  options = options.optionalCenterCrop();
99
+ transformation = new CenterCrop();
92
100
  break;
93
101
  case CENTER_INSIDE:
94
102
  case FIT_XY:
95
103
  options = options.optionalCenterInside();
104
+ transformation = new CenterInside();
96
105
  break;
97
106
  case FIT_CENTER:
98
107
  case FIT_START:
99
108
  case FIT_END:
100
109
  options = options.optionalFitCenter();
110
+ transformation = new FitCenter();
101
111
  break;
102
112
  default:
103
113
  break;
104
114
  }
105
115
  }
116
+ if (blur != null) {
117
+ options = transformation == null
118
+ ? options.optionalTransform(blur)
119
+ : options.optionalTransform(new MultiTransformation<>(transformation, blur));
120
+ }
106
121
  return options.downsample(capture);
107
122
  }
108
123
 
@@ -22,6 +22,7 @@ import com.facebook.react.uimanager.LayoutShadowNode;
22
22
  import com.facebook.react.uimanager.SimpleViewManager;
23
23
  import com.facebook.react.bridge.ReactContext;
24
24
  import com.facebook.react.uimanager.ThemedReactContext;
25
+ import com.facebook.react.uimanager.PixelUtil;
25
26
  import com.facebook.react.uimanager.annotations.ReactProp;
26
27
  import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper;
27
28
 
@@ -99,6 +100,11 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl> imple
99
100
  view.setImageRendering(imageRendering);
100
101
  }
101
102
 
103
+ @ReactProp(name = "blurRadius")
104
+ public void setBlurRadius(FastImageViewWithUrl view, float blurRadius) {
105
+ view.setBlurRadius(PixelUtil.toPixelFromDIP(blurRadius));
106
+ }
107
+
102
108
  @ReactProp(name = "paused")
103
109
  public void setPaused(FastImageViewWithUrl view, boolean paused) {
104
110
  view.setPaused(paused);
@@ -202,19 +202,50 @@ class FastImageViewWithUrl extends AppCompatImageView {
202
202
  mNeedsReload = true;
203
203
  }
204
204
 
205
+ // blurRadius, in pixels (0 is no blur). Part of the request (see
206
+ // reblur for a change).
207
+ private float mBlurRadius = 0;
208
+ private boolean mBlurChanged = false;
209
+
210
+ public void setBlurRadius(float blurRadius) {
211
+ if (blurRadius == mBlurRadius) return;
212
+ mBlurRadius = blurRadius;
213
+ mBlurChanged = true;
214
+ }
215
+
205
216
  // The scale type's options (what into() would apply), or none when
206
- // pixelated.
217
+ // pixelated, and the blur. A blurred animated image shows its first frame,
218
+ // as on iOS.
207
219
  private RequestOptions renderingOptions(Object model) {
220
+ FastImageBlur blur = mBlurRadius > 0 ? new FastImageBlur(mBlurRadius, getScaleType()) : null;
221
+ RequestOptions options;
208
222
  if (mPixelated) {
209
223
  // Decoded as it is, then scaled by the view without filtering (see
210
224
  // OwnGifTarget).
211
- return new RequestOptions()
225
+ options = new RequestOptions()
212
226
  .downsample(new FastImageSourceSize.Capture(DownsampleStrategy.NONE, String.valueOf(model)))
213
227
  .dontTransform();
228
+ if (blur != null) options = options.optionalTransform(blur);
229
+ } else {
230
+ options = FastImageSourceSize.scaleTypeOptions(
231
+ getScaleType(), FastImageSourceSize.capture(getScaleType(), model), blur);
214
232
  }
215
- return FastImageSourceSize.scaleTypeOptions(getScaleType(), FastImageSourceSize.capture(getScaleType(), model));
233
+ return blur != null ? options.dontAnimate() : options;
216
234
  }
217
235
 
236
+ // The request the loading one was made from, before renderingOptions, and
237
+ // its model, to make it again with another blur (see reblur).
238
+ @Nullable
239
+ private RequestBuilder<Drawable> mBaseRequest;
240
+ @Nullable
241
+ private Object mModel;
242
+ // Whether the loading image has loaded or failed.
243
+ private boolean mLoadEnded = false;
244
+ // Set by reblur: the next load is the same one again, so it doesn't send
245
+ // onLoadStart (it sends its other events, as the load it replaces would
246
+ // have).
247
+ private boolean mRestarting = false;
248
+
218
249
  // The request for the image the view shows once it has loaded, and the one
219
250
  // loading. A new source starts with the shown one as a thumbnail, from the
220
251
  // cache only, so the image stays until the new one has loaded instead of
@@ -255,6 +286,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
255
286
 
256
287
  // The loading image loaded (FastImageRequestListener).
257
288
  void onImageLoaded() {
289
+ mLoadEnded = true;
258
290
  mShownRequest = mLoadingRequest;
259
291
  mShownWidth = mLoadingWidth;
260
292
  mShownHeight = mLoadingHeight;
@@ -310,6 +342,34 @@ class FastImageViewWithUrl extends AppCompatImageView {
310
342
  into(shown, builder, meanwhile);
311
343
  }
312
344
 
345
+ // blurRadius changed, and nothing else that needs a reload: the image
346
+ // that's showing, blurred again, with no events, as reloadForSize does: it's
347
+ // decoded again from the cache and blurred on Glide's threads, and the view
348
+ // keeps the current image until then (or if that fails). A load that
349
+ // hasn't finished starts again with the new blur, without a second
350
+ // onLoadStart. Nothing loaded (defaultSource, which isn't blurred, or
351
+ // nothing): the next load has the new blur.
352
+ @SuppressLint("CheckResult")
353
+ private void reblur() {
354
+ if (requestManager == null || mBaseRequest == null) return;
355
+ RequestBuilder<Drawable> shown = mShownRequest;
356
+ if (shown != null && shown == mLoadingRequest) {
357
+ RequestBuilder<Drawable> request = mBaseRequest.clone().apply(renderingOptions(mModel));
358
+ RequestBuilder<Drawable> current = fromCache(shown);
359
+ Drawable meanwhile = shownCopy(shown);
360
+ mLoadCount++;
361
+ clearView(requestManager);
362
+ RequestBuilder<Drawable> builder = request.clone()
363
+ .error(current.clone())
364
+ .listener(new FastImageRequestListener(null, null, true, false));
365
+ if (meanwhile == null) builder = builder.thumbnail(current);
366
+ into(request, builder, meanwhile);
367
+ } else if (mLoadingRequest != null && !mLoadEnded) {
368
+ mRestarting = true;
369
+ mNeedsReload = true;
370
+ }
371
+ }
372
+
313
373
  // A copy of the image showing, for the view to show while the next one
314
374
  // loads, when it isn't in Glide's memory cache (source.memoryCache false):
315
375
  // a thumbnail of it would come from the disk, after the view has cleared,
@@ -346,6 +406,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
346
406
  private void into(RequestBuilder<Drawable> request, RequestBuilder<Drawable> builder, @Nullable Drawable meanwhile) {
347
407
  final int load = mLoadCount;
348
408
  mLoadingRequest = request;
409
+ mLoadEnded = false;
349
410
  mLoadingWidth = 0;
350
411
  mLoadingHeight = 0;
351
412
  OwnGifTarget target = new OwnGifTarget(meanwhile);
@@ -360,9 +421,16 @@ class FastImageViewWithUrl extends AppCompatImageView {
360
421
  builder.into(target);
361
422
  }
362
423
 
424
+ // Loading the image that's showing again (at a new size, or with another
425
+ // blur) failed: the view keeps showing it (FastImageRequestListener).
426
+ void onQuietLoadFailed() {
427
+ mLoadEnded = true;
428
+ }
429
+
363
430
  // The loading image failed (FastImageRequestListener). Glide shows
364
431
  // defaultSource then, but not over a thumbnail: show it here, as iOS does.
365
432
  void onImageFailed(boolean hadThumbnail) {
433
+ mLoadEnded = true;
366
434
  mShownRequest = null;
367
435
  if (!hadThumbnail) return;
368
436
  final int load = mLoadCount;
@@ -380,15 +448,24 @@ class FastImageViewWithUrl extends AppCompatImageView {
380
448
  @Nonnull FastImageViewManager manager,
381
449
  @Nullable RequestManager requestManager,
382
450
  @Nonnull Map<String, List<FastImageViewWithUrl>> viewsForUrlsMap) {
451
+ if (mBlurChanged && !mNeedsReload) {
452
+ mBlurChanged = false;
453
+ reblur();
454
+ }
383
455
  if (!mNeedsReload)
384
456
  return;
385
457
  // Only reload for changes that affect the request (source,
386
458
  // defaultSource, resizeMode), not for every prop update.
387
459
  mNeedsReload = false;
460
+ mBlurChanged = false;
461
+ boolean restarting = mRestarting;
462
+ mRestarting = false;
388
463
  mLoadCount++;
389
464
  RequestBuilder<Drawable> shownRequest = mShownRequest;
390
465
  mShownRequest = null;
391
466
  mLoadingRequest = null;
467
+ mBaseRequest = null;
468
+ mModel = null;
392
469
 
393
470
  // Nothing to show.
394
471
  if (mSource == null && mDefaultSource == null) {
@@ -453,7 +530,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
453
530
  }
454
531
  }
455
532
 
456
- if (imageSource != null) {
533
+ if (imageSource != null && !restarting) {
457
534
  // This is an orphan even without a load/loadend when only loading a placeholder
458
535
  FastImageEvents.send(this, FastImageViewManager.REACT_ON_LOAD_START_EVENT);
459
536
  }
@@ -461,7 +538,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
461
538
  if (requestManager != null) {
462
539
  // Records the image's own size when Glide decodes it, for onLoad.
463
540
  Object model = imageSource == null ? null : imageSource.getSourceForLoad();
464
- RequestBuilder<Drawable> builder =
541
+ RequestBuilder<Drawable> base =
465
542
  requestManager
466
543
  // This will make this work for remote and local images. e.g.
467
544
  // - file:///
@@ -473,10 +550,12 @@ class FastImageViewWithUrl extends AppCompatImageView {
473
550
  .apply(FastImageViewConverter
474
551
  .getOptions(getContext(), imageSource, mSource)
475
552
  .placeholder(mDefaultSource) // show until loaded
476
- .fallback(mDefaultSource)) // null will not be treated as error
477
- // What into() would apply for the scale type, with
478
- // the size capture, for imageRendering.
479
- .apply(renderingOptions(model));
553
+ .fallback(mDefaultSource)); // null will not be treated as error
554
+ mBaseRequest = model == null ? null : base;
555
+ mModel = model;
556
+ // What into() would apply for the scale type, with the size
557
+ // capture, for imageRendering, and the blur.
558
+ RequestBuilder<Drawable> builder = base.clone().apply(renderingOptions(model));
480
559
  RequestBuilder<Drawable> request = builder.clone();
481
560
 
482
561
  if (model == null) meanwhile = null;
@@ -118,6 +118,7 @@ export type FastImageProps = $ReadOnly<{|
118
118
  imageRendering?: ?('auto' | 'smooth' | 'pixelated'),
119
119
  paused?: ?boolean,
120
120
  downsample?: ?boolean,
121
+ blurRadius?: ?number,
121
122
  testID?: ?string,
122
123
  |}>
123
124
 
package/dist/index.d.ts CHANGED
@@ -142,6 +142,14 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
142
142
  * or with an image CDN instead). See the README.
143
143
  */
144
144
  downsample?: boolean;
145
+ /**
146
+ * Blurs the image by this radius, in points, like React Native's Image
147
+ * (the same radius looks about the same on both platforms). `0` (default)
148
+ * is no blur. For still images or occasional changes, not for animating.
149
+ * Only the loaded image is blurred, not `defaultSource`. An animated image
150
+ * shows its first frame, blurred. See the README.
151
+ */
152
+ blurRadius?: number;
145
153
  onLoadStart?(): void;
146
154
  onProgress?(event: OnProgressEvent): void;
147
155
  onLoad?(event: OnLoadEvent): void;
@@ -118,6 +118,7 @@ export type FastImageProps = $ReadOnly<{|
118
118
  imageRendering?: ?('auto' | 'smooth' | 'pixelated'),
119
119
  paused?: ?boolean,
120
120
  downsample?: ?boolean,
121
+ blurRadius?: ?number,
121
122
  testID?: ?string,
122
123
  |}>
123
124
 
@@ -32,6 +32,8 @@
32
32
  @property (nonatomic, assign) BOOL paused;
33
33
  // Decodes images at about the view's size instead of at full size.
34
34
  @property (nonatomic, assign) BOOL downsample;
35
+ // Blurs the loaded image by this radius, in points (0 is no blur).
36
+ @property (nonatomic, assign) CGFloat blurRadius;
35
37
 
36
38
  @end
37
39
 
@@ -5,6 +5,7 @@
5
5
  #import <SDWebImage/SDWebImageError.h>
6
6
  #import <SDWebImage/SDImageTransformer.h>
7
7
  #import "FFFDownsampledImage.h"
8
+ #import <Accelerate/Accelerate.h>
8
9
 
9
10
  @interface FFFastImageView ()
10
11
 
@@ -16,9 +17,15 @@
16
17
 
17
18
  @property(nonatomic, strong) NSDictionary* onLoadEvent;
18
19
  @property(nonatomic, strong) NSDictionary* onErrorEvent;
19
- // The image before tinting, kept while a tint is applied so the tint can be
20
- // changed or removed. nil when there's no tint (super.image is untinted).
21
- @property(nonatomic, strong) UIImage* untintedImage;
20
+ // The image before tinting and blurring, kept while either is applied so they
21
+ // can be changed or removed. nil when neither is (super.image is the image).
22
+ @property(nonatomic, strong) UIImage* originalImage;
23
+ // Counts images set, so a blur that finishes can tell if it's still current.
24
+ @property(nonatomic, assign) NSUInteger imageCount;
25
+ // The view's size when the image showing was blurred.
26
+ @property(nonatomic, assign) CGSize blurredForSize;
27
+ // The blurred image showing (untinted), so a tint change can re-show it.
28
+ @property(nonatomic, strong) UIImage* blurredImage;
22
29
  // Whether the current load was already restarted after the app came back
23
30
  // from the background (see downloadImage:).
24
31
  @property(nonatomic, assign) BOOL retriedAfterBackground;
@@ -41,6 +48,8 @@
41
48
  // When the app last went to the background (CACurrentMediaTime), or 0.
42
49
  static CFTimeInterval FFFEnteredBackgroundAt = 0;
43
50
 
51
+ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, BOOL downscale);
52
+
44
53
  @implementation FFFastImageView
45
54
 
46
55
  + (void) initialize {
@@ -128,6 +137,10 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
128
137
  if (_resizeMode != resizeMode) {
129
138
  _resizeMode = resizeMode;
130
139
  [self updateContentMode];
140
+ // The blur is made for the size the image is shown at.
141
+ if ([self blurs: self.originalImage]) {
142
+ [self setImage: self.originalImage];
143
+ }
131
144
  }
132
145
  }
133
146
 
@@ -153,6 +166,7 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
153
166
  - (void) layoutSubviews {
154
167
  [super layoutSubviews];
155
168
  [self updateContentMode];
169
+ [self blurAgainIfResized];
156
170
  if (self.waitsForSize) {
157
171
  if ([self hasSize]) {
158
172
  [self reloadImage];
@@ -198,8 +212,16 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
198
212
 
199
213
  - (void) setImageColor: (UIColor*)imageColor {
200
214
  _imageColor = imageColor;
201
- // Re-apply to the untinted image, so the tint can change or be removed.
202
- UIImage* image = self.untintedImage ?: super.image;
215
+ // A blurred image is a still image: show it with the new tint, without
216
+ // blurring it again.
217
+ if ([self blurs: self.originalImage]) {
218
+ if (self.blurredImage) {
219
+ [self showImage: self.blurredImage];
220
+ }
221
+ return;
222
+ }
223
+ // Re-apply to the original image, so the tint can change or be removed.
224
+ UIImage* image = self.originalImage ?: super.image;
203
225
  if (image) {
204
226
  // SDAnimatedImageView ignores the image it already shows, which would
205
227
  // keep an animated image's frames as they were tinted.
@@ -215,14 +237,26 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
215
237
  }
216
238
 
217
239
  - (void) setImage: (UIImage*)image {
218
- // Tinted as React Native's Image does it: UIKit draws a template image in
219
- // the view's tintColor, so no tinted copy of the image is made.
220
- // SDAnimatedImageView draws the frames of an animated image itself,
221
- // without the tint, so each frame is tinted as it's decoded (source-in,
222
- // as the template is), or, before SDWebImage 5.20, the first frame shows.
240
+ self.imageCount++;
241
+ if ([self blurs: image]) {
242
+ self.originalImage = image;
243
+ self.blurredImage = nil;
244
+ [self blurImage: image];
245
+ return;
246
+ }
247
+ self.originalImage = image && self.imageColor ? image : nil;
248
+ self.blurredImage = nil;
249
+ [self showImage: image];
250
+ }
251
+
252
+ // Shows the image, tinted as React Native's Image does it: UIKit draws a
253
+ // template image in the view's tintColor, so no tinted copy of the image is
254
+ // made. SDAnimatedImageView draws the frames of an animated image itself,
255
+ // without the tint, so each frame is tinted as it's decoded (source-in, as the
256
+ // template is), or, before SDWebImage 5.20, the first frame shows.
257
+ - (void) showImage: (UIImage*)image {
223
258
  UIColor* tint = image ? self.imageColor : nil;
224
259
  BOOL animated = [image conformsToProtocol: @protocol(SDAnimatedImage)] && [(id<SDAnimatedImage>)image animatedImageFrameCount] > 1;
225
- self.untintedImage = tint ? image : nil;
226
260
  self.tintColor = tint;
227
261
  if ([self tintsFrames]) {
228
262
  [self setValue: tint && animated ? [SDImageTintTransformer transformerWithColor: tint] : nil forKey: @"animationTransformer"];
@@ -247,6 +281,179 @@ static CFTimeInterval FFFEnteredBackgroundAt = 0;
247
281
  [self updateContentMode];
248
282
  }
249
283
 
284
+ - (void) setBlurRadius: (CGFloat)blurRadius {
285
+ if (_blurRadius == blurRadius) {
286
+ return;
287
+ }
288
+ _blurRadius = blurRadius;
289
+ // Re-apply to the original image, so the blur can change or be removed.
290
+ UIImage* image = self.originalImage ?: super.image;
291
+ if (image) {
292
+ [self setImage: image];
293
+ }
294
+ }
295
+
296
+ // Whether the image is blurred: a loaded image, not defaultSource (as with
297
+ // React Native's Image, and on Android).
298
+ - (BOOL) blurs: (UIImage*)image {
299
+ return _blurRadius > 0 && image && image != _defaultSource;
300
+ }
301
+
302
+ // Blurs the image off the main thread, and shows it (tinted as any image) unless
303
+ // another image was set meanwhile. Until then the view keeps showing what it
304
+ // showed: never the image without the blur. An animated image shows its first
305
+ // frame.
306
+ - (void) blurImage: (UIImage*)image {
307
+ // Not laid out yet (an image from the memory cache is set before the
308
+ // view's first layout): it's blurred for its size once it has one
309
+ // (blurAgainIfResized), instead of twice.
310
+ if (![self hasSize]) {
311
+ self.blurredForSize = CGSizeZero;
312
+ return;
313
+ }
314
+ CGFloat screenScale = self.window.screen.scale ?: [UIScreen mainScreen].scale;
315
+ // How many pixels on screen a pixel of the image takes.
316
+ CGFloat shownScale = [self shownScale: image] * screenScale / image.scale;
317
+ // Blurred at the size it's shown at (a blurred image needs no more
318
+ // detail), or at its own size if it's shown larger.
319
+ CGFloat scale = MIN(shownScale, 1);
320
+ // The radius in pixels of the image that's blurred.
321
+ CGFloat radius = _blurRadius * screenScale * scale / shownScale;
322
+ // Pixelated images are drawn larger without smoothing, so a smaller copy
323
+ // would show its pixels.
324
+ BOOL downscale = ![_imageRendering isEqualToString: @"pixelated"];
325
+ NSUInteger count = self.imageCount;
326
+ self.blurredForSize = self.bounds.size;
327
+ __weak typeof(self) weakSelf = self;
328
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
329
+ UIImage* blurred = FFFBlurredImage(image, scale, radius, downscale);
330
+ dispatch_async(dispatch_get_main_queue(), ^{
331
+ [weakSelf showBlurredImage: blurred count: count];
332
+ });
333
+ });
334
+ }
335
+
336
+ - (void) showBlurredImage: (UIImage*)image count: (NSUInteger)count {
337
+ if (self.imageCount == count) {
338
+ self.blurredImage = image;
339
+ [self showImage: image];
340
+ }
341
+ }
342
+
343
+ // Points on screen per point of the image, for the view's size and
344
+ // resizeMode, or 1 before the view has a size.
345
+ - (CGFloat) shownScale: (UIImage*)image {
346
+ CGSize size = image.size;
347
+ CGSize view = self.bounds.size;
348
+ if (size.width <= 0 || size.height <= 0 || view.width <= 0 || view.height <= 0) {
349
+ return 1;
350
+ }
351
+ CGFloat x = view.width / size.width;
352
+ CGFloat y = view.height / size.height;
353
+ switch (_resizeMode) {
354
+ case RCTResizeModeCover:
355
+ return MAX(x, y);
356
+ case RCTResizeModeContain:
357
+ return MIN(x, y);
358
+ case RCTResizeModeStretch:
359
+ return sqrt(x * y);
360
+ case RCTResizeModeCenter:
361
+ return MIN(MIN(x, y), 1);
362
+ default:
363
+ return 1;
364
+ }
365
+ }
366
+
367
+ // The view's size changed a lot (by more than a fifth either way) since the
368
+ // image was blurred, which was for the size it was shown at then: blur it
369
+ // again for the new size.
370
+ - (void) blurAgainIfResized {
371
+ if (![self blurs: self.originalImage] || ![self hasSize]) {
372
+ return;
373
+ }
374
+ CGSize view = self.bounds.size;
375
+ CGSize blurred = self.blurredForSize;
376
+ if (blurred.width > 0 && blurred.height > 0 &&
377
+ view.width <= blurred.width * 1.2 && blurred.width <= view.width * 1.2 &&
378
+ view.height <= blurred.height * 1.2 && blurred.height <= view.height * 1.2) {
379
+ return;
380
+ }
381
+ [self setImage: self.originalImage];
382
+ }
383
+
384
+ // The image (its first frame, for an animated image) drawn at scale times its
385
+ // size in pixels, and blurred as React Native's Image blurs on iOS: three box
386
+ // blurs, close to a Gaussian blur, with the box size worked out from the
387
+ // radius (in pixels) as the SVG spec describes and halved, as React Native
388
+ // does. A large box blurs a copy 2, 4 or 8 times smaller instead, with a box
389
+ // that much smaller, of at least 11 pixels: away from the edges that matches
390
+ // blurring at full size within a few levels of 255 (about as much as a
391
+ // slightly different radius changes it), for a quarter to a sixty-fourth of
392
+ // the work. It keeps the image's size in points.
393
+ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, BOOL downscale)
394
+ {
395
+ if (image.size.width <= 0 || image.size.height <= 0) {
396
+ return image;
397
+ }
398
+ double fullWidth = MAX(1, round(image.size.width * image.scale * scale));
399
+ double fullHeight = MAX(1, round(image.size.height * image.scale * scale));
400
+ // At most the image's larger side (a larger box looks the same, and an
401
+ // absurd radius would overflow).
402
+ double box = MIN(floor((radius * 3 * sqrt(2 * M_PI) / 4 + 0.5) / 2), MAX(fullWidth, fullHeight));
403
+ double factor = !downscale ? 1 : box >= 88 ? 8 : box >= 44 ? 4 : box >= 22 ? 2 : 1;
404
+ size_t width = MAX(1, (size_t) round(fullWidth / factor));
405
+ size_t height = MAX(1, (size_t) round(fullHeight / factor));
406
+ // Odd, so it's centered.
407
+ uint32_t boxSize = factor == 1 ? (uint32_t) box | 1 : (uint32_t) round((box / factor - 1) / 2) * 2 + 1;
408
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
409
+ CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, colorSpace,
410
+ kCGImageAlphaPremultipliedFirst | kCGBitmapByteOrder32Host);
411
+ CGColorSpaceRelease(colorSpace);
412
+ if (!context) {
413
+ return image;
414
+ }
415
+ // Drawn as UIKit draws (top-left origin), so it follows the image's
416
+ // orientation.
417
+ CGContextTranslateCTM(context, 0, height);
418
+ CGContextScaleCTM(context, 1, -1);
419
+ CGContextSetInterpolationQuality(context, kCGInterpolationHigh);
420
+ UIGraphicsPushContext(context);
421
+ [image drawInRect: CGRectMake(0, 0, width, height)];
422
+ UIGraphicsPopContext();
423
+
424
+ if (boxSize > 1) {
425
+ vImage_Buffer buffer = {
426
+ .data = CGBitmapContextGetData(context),
427
+ .height = height,
428
+ .width = width,
429
+ .rowBytes = CGBitmapContextGetBytesPerRow(context),
430
+ };
431
+ vImage_Buffer other = buffer;
432
+ other.data = malloc(buffer.rowBytes * height);
433
+ vImage_Error tempSize = vImageBoxConvolve_ARGB8888(&buffer, &other, NULL, 0, 0, boxSize, boxSize, NULL,
434
+ kvImageGetTempBufferSize | kvImageEdgeExtend);
435
+ void* temp = tempSize > 0 ? malloc(tempSize) : NULL;
436
+ if (other.data && temp) {
437
+ vImageBoxConvolve_ARGB8888(&buffer, &other, temp, 0, 0, boxSize, boxSize, NULL, kvImageEdgeExtend);
438
+ vImageBoxConvolve_ARGB8888(&other, &buffer, temp, 0, 0, boxSize, boxSize, NULL, kvImageEdgeExtend);
439
+ vImageBoxConvolve_ARGB8888(&buffer, &other, temp, 0, 0, boxSize, boxSize, NULL, kvImageEdgeExtend);
440
+ memcpy(buffer.data, other.data, buffer.rowBytes * height);
441
+ }
442
+ free(other.data);
443
+ free(temp);
444
+ }
445
+
446
+ CGImageRef cgImage = CGBitmapContextCreateImage(context);
447
+ CGContextRelease(context);
448
+ if (!cgImage) {
449
+ return image;
450
+ }
451
+ // The same size in points, also when it's smaller.
452
+ UIImage* blurred = [UIImage imageWithCGImage: cgImage scale: width / image.size.width orientation: UIImageOrientationUp];
453
+ CGImageRelease(cgImage);
454
+ return blurred;
455
+ }
456
+
250
457
  // The error's description, with the HTTP status code when there is one (as on
251
458
  // Android): SDWebImage keeps the code out of the description. Also for preload
252
459
  // results.
@@ -381,7 +588,7 @@ NSString *FFFErrorMessage(NSError *error)
381
588
  return;
382
589
  }
383
590
  // Already at full size (it's no larger than the view it was decoded for).
384
- UIImage* image = self.untintedImage ?: super.image;
591
+ UIImage* image = self.originalImage ?: super.image;
385
592
  if (self.hasCompleted && CGSizeEqualToSize(image.size, [FFFDownsampledImage sourceSizeOfImage: image])) {
386
593
  return;
387
594
  }
@@ -108,6 +108,7 @@ RCT_EXPORT_VIEW_PROPERTY(loopCount, NSInteger)
108
108
  RCT_EXPORT_VIEW_PROPERTY(imageRendering, NSString)
109
109
  RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
110
110
  RCT_EXPORT_VIEW_PROPERTY(downsample, BOOL)
111
+ RCT_EXPORT_VIEW_PROPERTY(blurRadius, CGFloat)
111
112
  RCT_EXPORT_VIEW_PROPERTY(onFastImageLoadStart, RCTDirectEventBlock)
112
113
  RCT_EXPORT_VIEW_PROPERTY(onFastImageProgress, RCTDirectEventBlock)
113
114
  RCT_EXPORT_VIEW_PROPERTY(onFastImageError, RCTDirectEventBlock)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-fast-image",
3
- "version": "8.20.0",
3
+ "version": "8.21.0",
4
4
  "description": "🚩 FastImage, performant React Native image component.",
5
5
  "keywords": [
6
6
  "cache",