react-native-fast-image 8.21.0 → 8.22.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
@@ -212,6 +212,27 @@ Pauses an animated image (GIF, and animated WebP on iOS) on the frame it's showi
212
212
 
213
213
  ---
214
214
 
215
+ ### `transition?: boolean | number | Transition`
216
+
217
+ Fades the image in when it loads. Off by default. `true` uses the platform's usual fade, a number is the duration in milliseconds, or pass an object:
218
+
219
+ - `duration`: milliseconds; 0 is no fade. Defaults to the platform's usual length: 300 ms on Android, 250 ms on iOS.
220
+ - `betweenImages` (default `false`): also fade between images. By default an image fades in when it appears over nothing (or over `defaultSource`), and a new `source` replaces the image that's showing at once, once it has loaded. With `true`, the new image cross-dissolves from the one showing (for a gallery or an avatar that changes, say).
221
+ - `skipOnCacheHit`: which images show at once, without the fade:
222
+ - `'memory'` (default): images from the memory cache, so a list scrolled back up shows images it already loaded at once. Images from the disk cache fade in.
223
+ - `'all'`: images from the memory or disk cache: images that download fade in, and local files and bundled images usually only the first time they load (the disk cache usually keeps them too).
224
+ - `'none'`: every image fades in.
225
+
226
+ Downloads, local files (`file://`, `content://`) and bundled images (`require()`) fade in. In lists that reuse views (e.g. FlashList), set `recyclingKey`, so a reused view starts empty and its image fades in, instead of showing the previous item's image until it loads.
227
+
228
+ ```jsx
229
+ <FastImage source={{ uri }} transition />
230
+ <FastImage source={{ uri }} transition={500} />
231
+ <FastImage source={{ uri }} transition={{ betweenImages: true, skipOnCacheHit: 'none' }} />
232
+ ```
233
+
234
+ ---
235
+
215
236
  ### `downsample?: boolean`
216
237
 
217
238
  iOS only. Decodes a large image at about the size it's shown at, instead of at full size, so it takes much less memory.
@@ -105,6 +105,21 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl> imple
105
105
  view.setBlurRadius(PixelUtil.toPixelFromDIP(blurRadius));
106
106
  }
107
107
 
108
+ @ReactProp(name = "transitionDuration")
109
+ public void setTransitionDuration(FastImageViewWithUrl view, int transitionDuration) {
110
+ view.setTransitionDuration(transitionDuration);
111
+ }
112
+
113
+ @ReactProp(name = "transitionBetweenImages")
114
+ public void setTransitionBetweenImages(FastImageViewWithUrl view, boolean betweenImages) {
115
+ view.setTransitionBetweenImages(betweenImages);
116
+ }
117
+
118
+ @ReactProp(name = "transitionSkipOnCacheHit")
119
+ public void setTransitionSkipOnCacheHit(FastImageViewWithUrl view, @Nullable String skipOnCacheHit) {
120
+ view.setTransitionSkipOnCacheHit(skipOnCacheHit);
121
+ }
122
+
108
123
  @ReactProp(name = "paused")
109
124
  public void setPaused(FastImageViewWithUrl view, boolean paused) {
110
125
  view.setPaused(paused);
@@ -2,9 +2,16 @@ package com.dylanvann.fastimage;
2
2
 
3
3
  import static com.dylanvann.fastimage.FastImageRequestListener.REACT_ON_ERROR_EVENT;
4
4
 
5
+ import android.animation.Animator;
6
+ import android.animation.AnimatorListenerAdapter;
7
+ import android.animation.ValueAnimator;
5
8
  import android.annotation.SuppressLint;
6
9
  import android.content.Context;
7
10
  import android.graphics.Bitmap;
11
+ import android.graphics.Canvas;
12
+ import android.graphics.Paint;
13
+ import android.graphics.PorterDuff;
14
+ import android.graphics.PorterDuffXfermode;
8
15
  import android.graphics.drawable.BitmapDrawable;
9
16
  import android.graphics.drawable.Drawable;
10
17
 
@@ -13,15 +20,20 @@ import androidx.annotation.Nullable;
13
20
  import androidx.appcompat.widget.AppCompatImageView;
14
21
  import androidx.core.view.ViewCompat;
15
22
 
23
+ import com.bumptech.glide.GenericTransitionOptions;
16
24
  import com.bumptech.glide.RequestBuilder;
17
25
  import com.bumptech.glide.RequestManager;
26
+ import com.bumptech.glide.load.DataSource;
18
27
  import com.bumptech.glide.load.model.GlideUrl;
19
28
  import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
29
+ import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
20
30
  import com.bumptech.glide.load.resource.gif.GifDrawable;
21
31
  import com.bumptech.glide.request.RequestOptions;
22
32
  import com.bumptech.glide.request.target.DrawableImageViewTarget;
23
33
  import com.bumptech.glide.request.target.SizeReadyCallback;
34
+ import com.bumptech.glide.request.transition.NoTransition;
24
35
  import com.bumptech.glide.request.transition.Transition;
36
+ import com.bumptech.glide.request.transition.TransitionFactory;
25
37
  import com.facebook.react.bridge.ReadableMap;
26
38
  import com.facebook.react.bridge.WritableMap;
27
39
  import com.facebook.react.bridge.WritableNativeMap;
@@ -88,6 +100,168 @@ class FastImageViewWithUrl extends AppCompatImageView {
88
100
  // Pauses GIFs on the frame they're showing (the view's own animation).
89
101
  private boolean mPaused = false;
90
102
 
103
+ // The `transition` prop (from the next load): how long a loaded image
104
+ // takes to fade in, in milliseconds (0 for no fade), whether it also fades
105
+ // over a loaded image (a new source), and which cache hits show at once
106
+ // (none, memory or all).
107
+ private int mTransitionDuration = 0;
108
+ private boolean mTransitionBetweenImages = false;
109
+ private String mTransitionSkipOnCacheHit = "memory";
110
+
111
+ public void setTransitionDuration(int transitionDuration) {
112
+ mTransitionDuration = Math.max(0, transitionDuration);
113
+ }
114
+
115
+ public void setTransitionBetweenImages(boolean betweenImages) {
116
+ mTransitionBetweenImages = betweenImages;
117
+ }
118
+
119
+ public void setTransitionSkipOnCacheHit(@Nullable String skipOnCacheHit) {
120
+ mTransitionSkipOnCacheHit = skipOnCacheHit == null ? "memory" : skipOnCacheHit;
121
+ }
122
+
123
+ // Fades a loaded image in, unless it's from a cache that skipOnCacheHit
124
+ // skips, as Glide's and Coil's cross-fades and Fresco decide: from the
125
+ // memory cache it shows at once (by default), and from the disk cache
126
+ // too with 'all'. Downloads, local files and bundled images fade.
127
+ private final class FadeFactory implements TransitionFactory<Drawable> {
128
+ private final int duration;
129
+ private final String skipOnCacheHit;
130
+
131
+ FadeFactory(int duration, String skipOnCacheHit) {
132
+ this.duration = duration;
133
+ this.skipOnCacheHit = skipOnCacheHit;
134
+ }
135
+
136
+ @Override
137
+ public Transition<Drawable> build(DataSource dataSource, boolean isFirstResource) {
138
+ if (skips(dataSource)) return NoTransition.get();
139
+ return new Transition<Drawable>() {
140
+ @Override
141
+ public boolean transition(Drawable current, ViewAdapter adapter) {
142
+ startFade(duration);
143
+ // The target shows the image as usual.
144
+ return false;
145
+ }
146
+ };
147
+ }
148
+
149
+ private boolean skips(DataSource dataSource) {
150
+ if (skipOnCacheHit.equals("none")) return false;
151
+ if (dataSource == DataSource.MEMORY_CACHE) return true;
152
+ // Glide also keeps a local file's or bundled image's decoded
153
+ // image in its disk cache (at the size it was decoded at, and not
154
+ // for GIFs), so with 'all' they usually only fade the first time.
155
+ boolean disk = dataSource == DataSource.DATA_DISK_CACHE || dataSource == DataSource.RESOURCE_DISK_CACHE;
156
+ return disk && skipOnCacheHit.equals("all");
157
+ }
158
+ }
159
+
160
+ // A fade in progress: what the view showed (null if nothing), drawn
161
+ // fading out while the view's image fades in, as a cross-dissolve on iOS.
162
+ // Drawn here rather than with Glide's DrawableCrossFadeTransition, whose
163
+ // TransitionDrawable stretches both images to one size and stays in the
164
+ // view after the fade, still drawing (and animating) the previous image.
165
+ @Nullable
166
+ private ValueAnimator mFade;
167
+ @Nullable
168
+ private Bitmap mFadeFrom;
169
+ private float mFadeProgress = 1f;
170
+ private final Paint mFadeFromPaint = new Paint(Paint.FILTER_BITMAP_FLAG);
171
+ private final Paint mFadeInPaint = new Paint();
172
+
173
+ {
174
+ // The two add up to a cross-dissolve: from * (1 - t) + image * t.
175
+ mFadeInPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.ADD));
176
+ }
177
+
178
+ private void startFade(int duration) {
179
+ int width = getWidth();
180
+ int height = getHeight();
181
+ if (width <= 0 || height <= 0) return;
182
+ // What the view shows now, including a fade in progress.
183
+ Bitmap from = null;
184
+ if (getDrawable() != null) {
185
+ try {
186
+ from = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
187
+ onDraw(new Canvas(from));
188
+ } catch (OutOfMemoryError | RuntimeException e) {
189
+ // Out of memory, or a hardware bitmap (Android 8+; an app's
190
+ // Glide module can enable them), which a software canvas
191
+ // can't draw: fade in over nothing instead.
192
+ if (from != null) from.recycle();
193
+ from = null;
194
+ }
195
+ }
196
+ endFade();
197
+ mFadeFrom = from;
198
+ mFadeProgress = 0f;
199
+ final ValueAnimator fade = ValueAnimator.ofFloat(0f, 1f);
200
+ fade.setDuration(duration);
201
+ fade.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
202
+ @Override
203
+ public void onAnimationUpdate(ValueAnimator animation) {
204
+ mFadeProgress = (float) animation.getAnimatedValue();
205
+ invalidate();
206
+ }
207
+ });
208
+ fade.addListener(new AnimatorListenerAdapter() {
209
+ @Override
210
+ public void onAnimationEnd(Animator animation) {
211
+ if (mFade == fade) endFade();
212
+ }
213
+ });
214
+ mFade = fade;
215
+ fade.start();
216
+ }
217
+
218
+ // Shows the image on its own (the fade ends, another starts, or the view
219
+ // is detached).
220
+ private void endFade() {
221
+ ValueAnimator fade = mFade;
222
+ mFade = null;
223
+ if (fade != null) fade.cancel();
224
+ if (mFadeFrom != null) {
225
+ mFadeFrom.recycle();
226
+ mFadeFrom = null;
227
+ }
228
+ mFadeProgress = 1f;
229
+ invalidate();
230
+ }
231
+
232
+ @SuppressWarnings("deprecation")
233
+ @Override
234
+ protected void onDraw(Canvas canvas) {
235
+ if (mFade == null) {
236
+ super.onDraw(canvas);
237
+ return;
238
+ }
239
+ int width = getWidth();
240
+ int height = getHeight();
241
+ int alpha = Math.round(255 * mFadeProgress);
242
+ if (mFadeFrom == null) {
243
+ // Fading in over nothing: one layer.
244
+ int count = canvas.saveLayerAlpha(0, 0, width, height, alpha, Canvas.ALL_SAVE_FLAG);
245
+ super.onDraw(canvas);
246
+ canvas.restoreToCount(count);
247
+ return;
248
+ }
249
+ // Canvas.ALL_SAVE_FLAG: the saveLayer without flags is API 21+.
250
+ int count = canvas.saveLayer(0, 0, width, height, null, Canvas.ALL_SAVE_FLAG);
251
+ mFadeFromPaint.setAlpha(255 - alpha);
252
+ canvas.drawBitmap(mFadeFrom, 0, 0, mFadeFromPaint);
253
+ mFadeInPaint.setAlpha(alpha);
254
+ canvas.saveLayer(0, 0, width, height, mFadeInPaint, Canvas.ALL_SAVE_FLAG);
255
+ super.onDraw(canvas);
256
+ canvas.restoreToCount(count);
257
+ }
258
+
259
+ @Override
260
+ protected void onDetachedFromWindow() {
261
+ endFade();
262
+ super.onDetachedFromWindow();
263
+ }
264
+
91
265
  public void setPaused(boolean paused) {
92
266
  if (paused == mPaused) return;
93
267
  mPaused = paused;
@@ -277,9 +451,10 @@ class FastImageViewWithUrl extends AppCompatImageView {
277
451
  mRecyclingKey = recyclingKey;
278
452
  if (changed) {
279
453
  // No thumbnail of the current image, so Glide clears the view to
280
- // defaultSource (or nothing) while the next one loads. Reload even
281
- // if the source is the same.
454
+ // defaultSource (or nothing) while the next one loads, nor a fade
455
+ // from it. Reload even if the source is the same.
282
456
  mShownRequest = null;
457
+ endFade();
283
458
  mNeedsReload = true;
284
459
  }
285
460
  }
@@ -299,6 +474,13 @@ class FastImageViewWithUrl extends AppCompatImageView {
299
474
  @Override
300
475
  protected void onSizeChanged(int w, int h, int oldw, int oldh) {
301
476
  super.onSizeChanged(w, h, oldw, oldh);
477
+ // During a fade, what the view showed was drawn at the old size: the
478
+ // image fades in over nothing instead.
479
+ if (mFadeFrom != null && (w != mFadeFrom.getWidth() || h != mFadeFrom.getHeight())) {
480
+ mFadeFrom.recycle();
481
+ mFadeFrom = null;
482
+ invalidate();
483
+ }
302
484
  reloadIfResized();
303
485
  }
304
486
 
@@ -395,7 +577,9 @@ class FastImageViewWithUrl extends AppCompatImageView {
395
577
  // it was loaded at: the key it's in Glide's memory cache under.
396
578
  @SuppressLint("CheckResult")
397
579
  private RequestBuilder<Drawable> fromCache(RequestBuilder<Drawable> shown) {
398
- RequestBuilder<Drawable> request = shown.clone().onlyRetrieveFromCache(true);
580
+ // Never fades: it's the image the view showed.
581
+ RequestBuilder<Drawable> request = shown.clone().onlyRetrieveFromCache(true)
582
+ .transition(GenericTransitionOptions.<Drawable>withNoTransition());
399
583
  if (mShownWidth > 0 && mShownHeight > 0) request = request.override(mShownWidth, mShownHeight);
400
584
  return request;
401
585
  }
@@ -461,6 +645,8 @@ class FastImageViewWithUrl extends AppCompatImageView {
461
645
  boolean restarting = mRestarting;
462
646
  mRestarting = false;
463
647
  mLoadCount++;
648
+ // A fade in progress carries on, as on iOS: into the next image if it
649
+ // shows at once, and a next image that fades starts from it.
464
650
  RequestBuilder<Drawable> shownRequest = mShownRequest;
465
651
  mShownRequest = null;
466
652
  mLoadingRequest = null;
@@ -560,6 +746,16 @@ class FastImageViewWithUrl extends AppCompatImageView {
560
746
 
561
747
  if (model == null) meanwhile = null;
562
748
  boolean thumbnail = shownRequest != null && model != null && meanwhile == null;
749
+ // After the clone: loading the image that's showing again (at a
750
+ // new size, or with another blur) doesn't fade it in again. Over a
751
+ // loaded image (a new source) only with betweenImages: otherwise it
752
+ // replaces it at once, as Glide, Coil and Fresco fade an image in
753
+ // from nothing and never between images.
754
+ boolean replacing = shownRequest != null && model != null;
755
+ if (mTransitionDuration > 0 && (!replacing || mTransitionBetweenImages)) {
756
+ builder = builder.transition(DrawableTransitionOptions.with(
757
+ new FadeFactory(mTransitionDuration, mTransitionSkipOnCacheHit)));
758
+ }
563
759
  if (thumbnail) {
564
760
  builder = builder.thumbnail(fromCache(shownRequest));
565
761
  }
package/dist/index.cjs.js CHANGED
@@ -91,11 +91,24 @@ function withProgress(onProgress) {
91
91
  onProgress(event);
92
92
  });
93
93
  }
94
+ const DEFAULT_FADE_MS = react_native.Platform.OS === "ios" ? 250 : 300;
95
+ function transitionProps(transition) {
96
+ var _transition$duration;
97
+ const { duration, betweenImages, skipOnCacheHit } = typeof transition === "number" ? { duration: transition } : typeof transition === "object" && transition ? {
98
+ ...transition,
99
+ duration: (_transition$duration = transition.duration) !== null && _transition$duration !== void 0 ? _transition$duration : DEFAULT_FADE_MS
100
+ } : { duration: transition ? DEFAULT_FADE_MS : 0 };
101
+ return {
102
+ transitionDuration: typeof duration === "number" && duration > 0 ? duration : 0,
103
+ transitionBetweenImages: !!betweenImages,
104
+ transitionSkipOnCacheHit: skipOnCacheHit || "memory"
105
+ };
106
+ }
94
107
  function withoutCache(source) {
95
108
  const { cache: _cache, ...rest } = source || {};
96
109
  return rest;
97
110
  }
98
- function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgress, onLoad, onError, onLoadEnd, style, fallback, children, resizeMode = "cover", loop, forwardedRef, onLayout, pointerEvents, ...viewProps }) {
111
+ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgress, onLoad, onError, onLoadEnd, style, fallback, children, resizeMode = "cover", loop, transition, forwardedRef, onLayout, pointerEvents, ...viewProps }) {
99
112
  const { onClick, ...props } = viewProps;
100
113
  const fallbackResult = (0, react.useRef)(void 0);
101
114
  const wrapperProps = {
@@ -158,6 +171,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
158
171
  ...imageProps,
159
172
  tintColor: resolvedTintColor,
160
173
  loopCount: loopCount(loop),
174
+ ...transitionProps(transition),
161
175
  style: react_native.StyleSheet.absoluteFill,
162
176
  source: resolvedSource,
163
177
  defaultSource: resolvedDefaultSource,
@@ -72,6 +72,12 @@ export type CachePathResult =
72
72
  | {| ok: true, path: string |}
73
73
  | {| ok: false, error: string |}
74
74
 
75
+ export type Transition = $ReadOnly<{|
76
+ duration?: ?number,
77
+ betweenImages?: ?boolean,
78
+ skipOnCacheHit?: ?('none' | 'memory' | 'all'),
79
+ |}>
80
+
75
81
  export type PreloadSuccess = {|
76
82
  uri: string,
77
83
  ok: true,
@@ -117,6 +123,7 @@ export type FastImageProps = $ReadOnly<{|
117
123
  loop?: ?(boolean | number),
118
124
  imageRendering?: ?('auto' | 'smooth' | 'pixelated'),
119
125
  paused?: ?boolean,
126
+ transition?: ?(boolean | number | Transition),
120
127
  downsample?: ?boolean,
121
128
  blurRadius?: ?number,
122
129
  testID?: ?string,
package/dist/index.d.ts CHANGED
@@ -42,6 +42,35 @@ export type Source = {
42
42
  */
43
43
  memoryCache?: boolean;
44
44
  };
45
+ export type Transition = {
46
+ /**
47
+ * How long the fade takes, in milliseconds; 0 means no fade. Defaults to
48
+ * the platform's usual length: 300 ms on Android, 250 ms on iOS.
49
+ */
50
+ duration?: number;
51
+ /**
52
+ * Also fades between images: a new `source` replacing an image that's
53
+ * showing cross-dissolves from it. Otherwise only an image that appears
54
+ * over nothing (or over `defaultSource`) fades in, and a new source
55
+ * replaces the image showing at once, once it has loaded.
56
+ * @default false
57
+ */
58
+ betweenImages?: boolean;
59
+ /**
60
+ * Skips the fade for an image from a cache, so images already loaded
61
+ * show at once, e.g. in a list scrolled back up or a reused row.
62
+ *
63
+ * - `'memory'`: skips it for images from the memory cache.
64
+ * - `'all'`: skips it for images from the memory or disk cache too, so
65
+ * images that download fade in, and local files and bundled images
66
+ * usually only the first time (the disk cache usually keeps them too).
67
+ * - `'none'`: always fades.
68
+ *
69
+ * Downloads, local files and bundled images (`require()`) fade in.
70
+ * @default 'memory'
71
+ */
72
+ skipOnCacheHit?: 'none' | 'memory' | 'all' | null;
73
+ };
45
74
  export interface OnLoadEvent {
46
75
  nativeEvent: {
47
76
  width: number;
@@ -135,6 +164,13 @@ export interface FastImageProps extends AccessibilityProps, ViewProps {
135
164
  * its own, so pausing one doesn't pause others showing the same file.
136
165
  */
137
166
  paused?: boolean;
167
+ /**
168
+ * Fades the image in when it loads: `true` for the platform's usual fade,
169
+ * a duration in milliseconds, or a `Transition` (default: no fade). An
170
+ * image fades in when it appears over nothing (see `betweenImages`),
171
+ * unless it's from the memory cache (see `skipOnCacheHit`).
172
+ */
173
+ transition?: boolean | number | Transition | null;
138
174
  /**
139
175
  * iOS only. Decodes a large image at about the size it's shown at, so it
140
176
  * takes much less memory. For images much larger than their views that
package/dist/index.js CHANGED
@@ -64,11 +64,24 @@ function withProgress(onProgress) {
64
64
  onProgress(event);
65
65
  });
66
66
  }
67
+ const DEFAULT_FADE_MS = Platform.OS === "ios" ? 250 : 300;
68
+ function transitionProps(transition) {
69
+ var _transition$duration;
70
+ const { duration, betweenImages, skipOnCacheHit } = typeof transition === "number" ? { duration: transition } : typeof transition === "object" && transition ? {
71
+ ...transition,
72
+ duration: (_transition$duration = transition.duration) !== null && _transition$duration !== void 0 ? _transition$duration : DEFAULT_FADE_MS
73
+ } : { duration: transition ? DEFAULT_FADE_MS : 0 };
74
+ return {
75
+ transitionDuration: typeof duration === "number" && duration > 0 ? duration : 0,
76
+ transitionBetweenImages: !!betweenImages,
77
+ transitionSkipOnCacheHit: skipOnCacheHit || "memory"
78
+ };
79
+ }
67
80
  function withoutCache(source) {
68
81
  const { cache: _cache, ...rest } = source || {};
69
82
  return rest;
70
83
  }
71
- function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgress, onLoad, onError, onLoadEnd, style, fallback, children, resizeMode = "cover", loop, forwardedRef, onLayout, pointerEvents, ...viewProps }) {
84
+ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgress, onLoad, onError, onLoadEnd, style, fallback, children, resizeMode = "cover", loop, transition, forwardedRef, onLayout, pointerEvents, ...viewProps }) {
72
85
  const { onClick, ...props } = viewProps;
73
86
  const fallbackResult = useRef(void 0);
74
87
  const wrapperProps = {
@@ -131,6 +144,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
131
144
  ...imageProps,
132
145
  tintColor: resolvedTintColor,
133
146
  loopCount: loopCount(loop),
147
+ ...transitionProps(transition),
134
148
  style: StyleSheet.absoluteFill,
135
149
  source: resolvedSource,
136
150
  defaultSource: resolvedDefaultSource,
@@ -72,6 +72,12 @@ export type CachePathResult =
72
72
  | {| ok: true, path: string |}
73
73
  | {| ok: false, error: string |}
74
74
 
75
+ export type Transition = $ReadOnly<{|
76
+ duration?: ?number,
77
+ betweenImages?: ?boolean,
78
+ skipOnCacheHit?: ?('none' | 'memory' | 'all'),
79
+ |}>
80
+
75
81
  export type PreloadSuccess = {|
76
82
  uri: string,
77
83
  ok: true,
@@ -117,6 +123,7 @@ export type FastImageProps = $ReadOnly<{|
117
123
  loop?: ?(boolean | number),
118
124
  imageRendering?: ?('auto' | 'smooth' | 'pixelated'),
119
125
  paused?: ?boolean,
126
+ transition?: ?(boolean | number | Transition),
120
127
  downsample?: ?boolean,
121
128
  blurRadius?: ?number,
122
129
  testID?: ?string,
@@ -30,6 +30,12 @@
30
30
  @property (nonatomic, copy) NSString *imageRendering;
31
31
  // Pauses animated images on the frame they're showing.
32
32
  @property (nonatomic, assign) BOOL paused;
33
+ // The `transition` prop: how long a loaded image takes to fade in, in
34
+ // milliseconds (0 for no fade), whether it also fades over a loaded image
35
+ // (a new source), and which cache hits show at once (none, memory or all).
36
+ @property (nonatomic, assign) double transitionDuration;
37
+ @property (nonatomic, assign) BOOL transitionBetweenImages;
38
+ @property (nonatomic, copy) NSString *transitionSkipOnCacheHit;
33
39
  // Decodes images at about the view's size instead of at full size.
34
40
  @property (nonatomic, assign) BOOL downsample;
35
41
  // Blurs the loaded image by this radius, in points (0 is no blur).
@@ -26,6 +26,11 @@
26
26
  @property(nonatomic, assign) CGSize blurredForSize;
27
27
  // The blurred image showing (untinted), so a tint change can re-show it.
28
28
  @property(nonatomic, strong) UIImage* blurredImage;
29
+ // The image being blurred fades in once it's blurred (transition).
30
+ @property(nonatomic, assign) BOOL fadesBlurredImage;
31
+ // The image being blurred is the first loaded one the view shows: until it's
32
+ // blurred, the view shows no loaded image (nothing, or defaultSource).
33
+ @property(nonatomic, assign) BOOL blursOverNothing;
29
34
  // Whether the current load was already restarted after the app came back
30
35
  // from the background (see downloadImage:).
31
36
  @property(nonatomic, assign) BOOL retriedAfterBackground;
@@ -117,6 +122,77 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
117
122
  }
118
123
  }
119
124
 
125
+ // Whether a loaded image fades in (the `transition` prop). Over a loaded
126
+ // image (a new source) only with betweenImages: otherwise it replaces it at
127
+ // once, as Android's image libraries fade an image in from nothing and never
128
+ // between images. Then as SDWebImage's transitions and those libraries
129
+ // decide: from the memory cache it shows at once (skipOnCacheHit 'memory',
130
+ // the default), and from the disk cache too with 'all'. Downloads, local
131
+ // files and bundled images fade.
132
+ - (BOOL) fadesImageFromCache: (SDImageCacheType)cacheType {
133
+ if (self.transitionDuration <= 0 || ([self displaysLoadedImage] && !self.transitionBetweenImages)) {
134
+ return NO;
135
+ }
136
+ NSString* skip = self.transitionSkipOnCacheHit;
137
+ if ([skip isEqualToString: @"none"]) {
138
+ return YES;
139
+ }
140
+ if (cacheType == SDImageCacheTypeMemory) {
141
+ return NO;
142
+ }
143
+ // SDWebImage also keeps local files and bundled images in its disk cache,
144
+ // so with 'all' they usually only fade the first time.
145
+ return !(cacheType == SDImageCacheTypeDisk && [skip isEqualToString: @"all"]);
146
+ }
147
+
148
+ // Whether the view shows a loaded image now: a loaded image that's still
149
+ // being blurred isn't showing yet.
150
+ - (BOOL) displaysLoadedImage {
151
+ return self.showsLoadedImage && !self.blursOverNothing;
152
+ }
153
+
154
+ // Shows a loaded image, fading it in over what the view shows if `fade`. A
155
+ // blurred image shows, and fades in, once it's blurred.
156
+ - (void) showLoadedImage: (UIImage*)image fade: (BOOL)fade {
157
+ if ([self blurs: image]) {
158
+ self.blursOverNothing = ![self displaysLoadedImage];
159
+ self.fadesBlurredImage = fade;
160
+ self.image = image;
161
+ return;
162
+ }
163
+ self.blursOverNothing = NO;
164
+ self.fadesBlurredImage = NO;
165
+ if (fade) {
166
+ [self fadeIn: ^{
167
+ self.image = image;
168
+ }];
169
+ } else {
170
+ self.image = image;
171
+ }
172
+ }
173
+
174
+ // Makes the change (showing another image), fading it in over what the view
175
+ // showed.
176
+ - (void) fadeIn: (void (^)(void))change {
177
+ if (!super.image || !self.layer.presentationLayer) {
178
+ // Nothing to cross-dissolve from: the view shows no image, or isn't
179
+ // on screen yet (e.g. a new view whose image loads as it mounts),
180
+ // where a cross-dissolve has nothing drawn to start from and shows
181
+ // the image at once. Fade the view in instead.
182
+ change();
183
+ CABasicAnimation* fadeIn = [CABasicAnimation animationWithKeyPath: @"opacity"];
184
+ fadeIn.fromValue = @0;
185
+ fadeIn.duration = self.transitionDuration / 1000;
186
+ [self.layer addAnimation: fadeIn forKey: @"FFFFadeIn"];
187
+ return;
188
+ }
189
+ [UIView transitionWithView: self
190
+ duration: self.transitionDuration / 1000
191
+ options: UIViewAnimationOptionTransitionCrossDissolve | UIViewAnimationOptionAllowUserInteraction
192
+ animations: change
193
+ completion: nil];
194
+ }
195
+
120
196
  - (void) setPaused: (BOOL)paused {
121
197
  if (_paused == paused) {
122
198
  return;
@@ -246,7 +322,17 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
246
322
  }
247
323
  self.originalImage = image && self.imageColor ? image : nil;
248
324
  self.blurredImage = nil;
249
- [self showImage: image];
325
+ self.blursOverNothing = NO;
326
+ // The blur was removed before it was ready: the fade it was waiting for
327
+ // happens now.
328
+ if (self.fadesBlurredImage) {
329
+ self.fadesBlurredImage = NO;
330
+ [self fadeIn: ^{
331
+ [self showImage: image];
332
+ }];
333
+ } else {
334
+ [self showImage: image];
335
+ }
250
336
  }
251
337
 
252
338
  // Shows the image, tinted as React Native's Image does it: UIKit draws a
@@ -336,7 +422,15 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
336
422
  - (void) showBlurredImage: (UIImage*)image count: (NSUInteger)count {
337
423
  if (self.imageCount == count) {
338
424
  self.blurredImage = image;
339
- [self showImage: image];
425
+ self.blursOverNothing = NO;
426
+ if (self.fadesBlurredImage) {
427
+ self.fadesBlurredImage = NO;
428
+ [self fadeIn: ^{
429
+ [self showImage: image];
430
+ }];
431
+ } else {
432
+ [self showImage: image];
433
+ }
340
434
  }
341
435
  }
342
436
 
@@ -513,8 +607,9 @@ NSString *FFFErrorMessage(NSError *error)
513
607
  if (changed) {
514
608
  // The view shows other content now: don't keep the current image
515
609
  // while the next one loads (reloadImage clears it), even if the
516
- // source is the same.
610
+ // source is the same, nor a fade from it.
517
611
  self.showsLoadedImage = NO;
612
+ [self.layer removeAllAnimations];
518
613
  _needsReload = YES;
519
614
  }
520
615
  }
@@ -646,7 +741,7 @@ NSString *FFFErrorMessage(NSError *error)
646
741
  }
647
742
  return;
648
743
  }
649
- [self setImage: image];
744
+ [self showLoadedImage: image fade: [self fadesImageFromCache: SDImageCacheTypeNone]];
650
745
  if (self.onFastImageProgress) {
651
746
  self.onFastImageProgress(@{
652
747
  @"loaded": @(1),
@@ -799,13 +894,18 @@ NSString *FFFErrorMessage(NSError *error)
799
894
  // class to SDAnimatedImage (loadContext sets it). Only while this is the
800
895
  // current load: SDWebImage sets the placeholder when a load it cancelled
801
896
  // completes, which could replace a newer image.
897
+ UIImage* placeholder = _defaultSource;
802
898
  SDSetImageBlock setImage = ^(UIImage* _Nullable image, NSData* _Nullable data, SDImageCacheType cacheType, NSURL* _Nullable imageURL) {
803
- if (weakSelf.loadCount == load) {
804
- weakSelf.image = image;
899
+ if (weakSelf.loadCount != load) {
900
+ return;
805
901
  }
902
+ // The loaded image, not defaultSource (shown while it loads, or if it
903
+ // fails).
904
+ BOOL loaded = image && image != placeholder;
905
+ [weakSelf showLoadedImage: image fade: loaded && [weakSelf fadesImageFromCache: cacheType]];
806
906
  };
807
907
  [self sd_internalSetImageWithURL: url
808
- placeholderImage: _defaultSource
908
+ placeholderImage: placeholder
809
909
  options: options
810
910
  context: context
811
911
  setImageBlock: setImage
@@ -107,6 +107,9 @@ RCT_EXPORT_VIEW_PROPERTY(recyclingKey, NSString)
107
107
  RCT_EXPORT_VIEW_PROPERTY(loopCount, NSInteger)
108
108
  RCT_EXPORT_VIEW_PROPERTY(imageRendering, NSString)
109
109
  RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
110
+ RCT_EXPORT_VIEW_PROPERTY(transitionDuration, double)
111
+ RCT_EXPORT_VIEW_PROPERTY(transitionBetweenImages, BOOL)
112
+ RCT_EXPORT_VIEW_PROPERTY(transitionSkipOnCacheHit, NSString)
110
113
  RCT_EXPORT_VIEW_PROPERTY(downsample, BOOL)
111
114
  RCT_EXPORT_VIEW_PROPERTY(blurRadius, CGFloat)
112
115
  RCT_EXPORT_VIEW_PROPERTY(onFastImageLoadStart, RCTDirectEventBlock)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-fast-image",
3
- "version": "8.21.0",
3
+ "version": "8.22.0",
4
4
  "description": "🚩 FastImage, performant React Native image component.",
5
5
  "keywords": [
6
6
  "cache",