react-native-fast-image 8.24.0 → 8.26.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
@@ -175,6 +175,33 @@ Whether the decoded image is kept in the memory cache. **Default: true.** With `
175
175
 
176
176
  ---
177
177
 
178
+ ### Several sizes of an image
179
+
180
+ `source` can also be an array of the same image at different sizes, each with its `width` and `height` in pixels (times `scale`, if it has one). The view loads the one whose size is closest to its own, in pixels, so a small view downloads a small image:
181
+
182
+ ```jsx
183
+ <FastImage
184
+ style={{ width: 120, height: 120 }}
185
+ source={[
186
+ { uri: 'https://example.com/photo-200.jpg', width: 200, height: 200 },
187
+ { uri: 'https://example.com/photo-800.jpg', width: 800, height: 800 },
188
+ {
189
+ uri: 'https://example.com/photo-2000.jpg',
190
+ width: 2000,
191
+ height: 2000,
192
+ },
193
+ ]}
194
+ />
195
+ ```
196
+
197
+ - The view picks once it has been laid out. One that has no size (e.g. sized from `onLoad`) loads the largest.
198
+ - When the view's size changes so that another size fits better, it loads that one (with its load events), and keeps showing the current image until then, without a `transition`.
199
+ - Each size is cached separately. A `cacheKey` applies to its own entry: give each size its own key (or none), or the sizes would replace each other in the cache.
200
+ - `FastImage.preload`, `getCachePath` and `writeToCache` take one size: pass the one a view will show. An array fails with `{ ok: false, error }`.
201
+ - An array of one is the same as that source.
202
+
203
+ ---
204
+
178
205
  ### `defaultSource?: number`
179
206
 
180
207
  - An asset loaded with `require(...)`.
@@ -199,6 +226,7 @@ For views that get reused for other content, such as rows in FlashList or recycl
199
226
  - `FastImage.resizeMode.cover` **(Default)** - 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 larger than the corresponding dimension of the view (minus padding).
200
227
  - `FastImage.resizeMode.stretch` - Scale width and height independently, This may change the aspect ratio of the src.
201
228
  - `FastImage.resizeMode.center` - Do not scale the image, keep centered.
229
+ - `FastImage.resizeMode.repeat` - Repeat the image to cover the view, from its top-left corner, at the image's own size in pixels (a bundled image at its size in points), scaled down to fit if it's larger than the view. An animated image repeats its first frame, and `defaultSource` repeats too.
202
230
 
203
231
  ---
204
232
 
@@ -511,8 +539,9 @@ With [react-native-web](https://necolas.github.io/react-native-web/), FastImage
511
539
 
512
540
  Support is minimal:
513
541
 
514
- - Props that work: `source` (a `uri`, or a `require()`d image), `defaultSource`, `resizeMode`, `tintColor`, `blurRadius`, `style`, children, `onLoadStart`, `onLoad`, `onError`, `onLoadEnd`, and View props such as `testID`, accessibility props, `onLayout` and `pointerEvents`.
542
+ - Props that work: `source` (a `uri`, a `require()`d image, or several sizes), `defaultSource`, `resizeMode`, `tintColor`, `blurRadius`, `style`, children, `onLoadStart`, `onLoad`, `onError`, `onLoadEnd`, and View props such as `testID`, accessibility props, `onLayout` and `pointerEvents`.
515
543
  - Ignored: `source.headers` (a browser can't send them for an image), `source.priority`, `source.cache`, `source.cacheKey`, `source.memoryCache`, `recyclingKey`, `loop`, `imageRendering`, `paused`, `transition`, `downsample`, `fallback` and `onProgress`.
544
+ - Several sizes are shown with a lazily loaded `<img>` whose `srcset` lists them, with `sizes="auto, 100vw"`: the browser loads the one for the width the image is shown at, in device pixels (usually the smallest that's at least as wide). Browsers that don't support `sizes="auto"` use the viewport's width. `tintColor`, `defaultSource` and `resizeMode="repeat"` don't apply to them.
516
545
  - `FastImage.preload` loads the images into the browser's cache and resolves with a result per source. `clearMemoryCache`, `clearDiskCache` and `configureCache` resolve without doing anything (the browser manages its cache), and `getCachePath` and `writeToCache` resolve with `{ ok: false, error: 'Not supported on the web' }`.
517
546
 
518
547
  ## Troubleshooting
@@ -14,6 +14,7 @@ import androidx.annotation.NonNull;
14
14
 
15
15
  import com.bumptech.glide.Glide;
16
16
  import com.bumptech.glide.RequestManager;
17
+ import com.facebook.react.bridge.ReadableArray;
17
18
  import com.facebook.react.bridge.ReadableMap;
18
19
  import com.facebook.react.bridge.WritableMap;
19
20
  import com.facebook.react.bridge.WritableNativeMap;
@@ -69,6 +70,11 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl> imple
69
70
  view.setSource(source);
70
71
  }
71
72
 
73
+ @ReactProp(name = "sources")
74
+ public void setSources(FastImageViewWithUrl view, @Nullable ReadableArray sources) {
75
+ view.setSources(sources);
76
+ }
77
+
72
78
  @ReactProp(name = "defaultSource")
73
79
  public void setDefaultSource(FastImageViewWithUrl view, @Nullable String source) {
74
80
  view.setDefaultSource(
@@ -127,8 +133,11 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl> imple
127
133
 
128
134
  @ReactProp(name = "resizeMode")
129
135
  public void setResizeMode(FastImageViewWithUrl view, String resizeMode) {
130
- final FastImageViewWithUrl.ScaleType scaleType = FastImageViewConverter.getScaleType(resizeMode);
131
- view.setResizeMode(scaleType);
136
+ // repeat fills the view with the tiled image (see setImageDrawable).
137
+ boolean repeat = "repeat".equals(resizeMode);
138
+ final FastImageViewWithUrl.ScaleType scaleType =
139
+ repeat ? FastImageViewWithUrl.ScaleType.FIT_XY : FastImageViewConverter.getScaleType(resizeMode);
140
+ view.setResizeMode(scaleType, repeat);
132
141
  }
133
142
 
134
143
  @Override
@@ -12,6 +12,7 @@ import android.graphics.Canvas;
12
12
  import android.graphics.Paint;
13
13
  import android.graphics.PorterDuff;
14
14
  import android.graphics.PorterDuffXfermode;
15
+ import android.graphics.Shader;
15
16
  import android.graphics.drawable.BitmapDrawable;
16
17
  import android.graphics.drawable.Drawable;
17
18
 
@@ -34,6 +35,7 @@ import com.bumptech.glide.request.target.SizeReadyCallback;
34
35
  import com.bumptech.glide.request.transition.NoTransition;
35
36
  import com.bumptech.glide.request.transition.Transition;
36
37
  import com.bumptech.glide.request.transition.TransitionFactory;
38
+ import com.facebook.react.bridge.ReadableArray;
37
39
  import com.facebook.react.bridge.ReadableMap;
38
40
  import com.facebook.react.bridge.WritableMap;
39
41
  import com.facebook.react.bridge.WritableNativeMap;
@@ -62,7 +64,89 @@ class FastImageViewWithUrl extends AppCompatImageView {
62
64
 
63
65
  public void setSource(@Nullable ReadableMap source) {
64
66
  mNeedsReload = true;
65
- mSource = source;
67
+ mPropSource = source;
68
+ if (mSources == null) mSource = source;
69
+ }
70
+
71
+ // The `source` prop. With several `sources`, mSource is the one picked for
72
+ // the view's size instead.
73
+ @Nullable
74
+ private ReadableMap mPropSource = null;
75
+ // Several sources (2 or more) of the same image at different sizes, or
76
+ // null: the view loads the one whose size is closest to its own.
77
+ @Nullable
78
+ private ReadableArray mSources = null;
79
+ // Several sources: the index of the one picked (-1 before one is), and
80
+ // whether the load waits for the view's size (after layout), or has
81
+ // waited (then it loads the largest if the view still has no size).
82
+ private int mSourceIndex = -1;
83
+ private boolean mWaitsForSize = false;
84
+ private boolean mWaitedForSize = false;
85
+ // Several sources: the next load switches to another one for a new view
86
+ // size. It's the same picture at another size, so it doesn't fade in.
87
+ private boolean mSwitchesSource = false;
88
+ // What the last update was called with, to load once the view has a size.
89
+ @Nullable
90
+ private FastImageViewManager mManager;
91
+ @Nullable
92
+ private Map<String, List<FastImageViewWithUrl>> mViewsForUrlsMap;
93
+
94
+ public void setSources(@Nullable ReadableArray sources) {
95
+ mNeedsReload = true;
96
+ mSources = sources != null && sources.size() > 1 ? sources : null;
97
+ mSourceIndex = -1;
98
+ if (mSources == null) mSource = mPropSource;
99
+ }
100
+
101
+ // Of several sources, the index of the one whose size in pixels (width ×
102
+ // height × scale²) is closest to the view's (by pixel count), or of the
103
+ // largest while the view has no size (e.g. one sized from onLoad).
104
+ private int sourceIndexForSize() {
105
+ ReadableArray sources = mSources;
106
+ if (sources == null) return -1;
107
+ double viewPixels = (double) getWidth() * getHeight();
108
+ int best = -1;
109
+ double bestFit = Double.MAX_VALUE;
110
+ for (int i = 0; i < sources.size(); i++) {
111
+ ReadableMap source = sources.getMap(i);
112
+ if (source == null) continue;
113
+ double scale = number(source, "scale", 1);
114
+ double pixels = number(source, "width", 0) * number(source, "height", 0) * scale * scale;
115
+ double fit = viewPixels > 0 ? Math.abs(1 - pixels / viewPixels) : -pixels;
116
+ if (best < 0 || fit < bestFit) {
117
+ best = i;
118
+ bestFit = fit;
119
+ }
120
+ }
121
+ return best;
122
+ }
123
+
124
+ // Picks the source for the view's size (several sources).
125
+ private void pickSource() {
126
+ mSourceIndex = sourceIndexForSize();
127
+ mSource = mSourceIndex >= 0 && mSources != null ? mSources.getMap(mSourceIndex) : null;
128
+ }
129
+
130
+ private static double number(ReadableMap map, String key, double fallback) {
131
+ return map.hasKey(key) && !map.isNull(key) ? map.getDouble(key) : fallback;
132
+ }
133
+
134
+ // Several sources: another one fits the view's new size better. Loads it
135
+ // (after layout), keeping the image showing until then, without a fade.
136
+ private boolean switchSourceIfResized() {
137
+ if (mSources == null || mNeedsReload || mWaitsForSize || getWidth() <= 0 || getHeight() <= 0
138
+ || mManager == null || sourceIndexForSize() == mSourceIndex) {
139
+ return false;
140
+ }
141
+ mSwitchesSource = true;
142
+ mNeedsReload = true;
143
+ post(new Runnable() {
144
+ @Override
145
+ public void run() {
146
+ if (mNeedsReload && mManager != null) onAfterUpdate(mManager, requestManager, mViewsForUrlsMap);
147
+ }
148
+ });
149
+ return true;
66
150
  }
67
151
 
68
152
  public void setDefaultSource(@Nullable Drawable source) {
@@ -354,14 +438,41 @@ class FastImageViewWithUrl extends AppCompatImageView {
354
438
  : mLoopCount);
355
439
  }
356
440
 
441
+ // resizeMode repeat: the image (a GIF's first frame) repeated from the
442
+ // top-left at its own size, scaled down to fit the view if it's larger,
443
+ // as React Native's Image does. The view fills with it (FIT_XY).
444
+ private boolean mRepeat = false;
445
+
357
446
  // Glide crops or fits the bitmap for the scale type when it loads, so a
358
447
  // new resizeMode needs a reload to take effect (#762).
359
- public void setResizeMode(ScaleType scaleType) {
360
- if (scaleType == getScaleType()) return;
448
+ public void setResizeMode(ScaleType scaleType, boolean repeat) {
449
+ if (scaleType == getScaleType() && repeat == mRepeat) return;
361
450
  setScaleType(scaleType);
451
+ mRepeat = repeat;
362
452
  mNeedsReload = true;
363
453
  }
364
454
 
455
+ // Repeats what the view shows (the loaded image, and defaultSource).
456
+ @Override
457
+ public void setImageDrawable(@Nullable Drawable drawable) {
458
+ super.setImageDrawable(mRepeat ? tiled(drawable) : drawable);
459
+ }
460
+
461
+ @Nullable
462
+ private Drawable tiled(@Nullable Drawable drawable) {
463
+ if (!(drawable instanceof BitmapDrawable)) return drawable;
464
+ BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
465
+ if (bitmapDrawable.getTileModeX() == Shader.TileMode.REPEAT) return drawable;
466
+ Bitmap bitmap = bitmapDrawable.getBitmap();
467
+ if (bitmap == null) return drawable;
468
+ // A copy of the drawable (not of the bitmap): Glide's may be shown by
469
+ // other views too.
470
+ BitmapDrawable tiled = new BitmapDrawable(getResources(), bitmap);
471
+ tiled.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
472
+ if (mPixelated) tiled.setFilterBitmap(false);
473
+ return tiled;
474
+ }
475
+
365
476
  // imageRendering="pixelated": no resampling by Glide, and drawn without
366
477
  // filtering (sharp pixels). "smooth" is iOS only; here it's the same as
367
478
  // "auto": Glide already decodes a large image at about the view's size, and
@@ -391,9 +502,20 @@ class FastImageViewWithUrl extends AppCompatImageView {
391
502
  // pixelated, and the blur. A blurred animated image shows its first frame,
392
503
  // as on iOS.
393
504
  private RequestOptions renderingOptions(Object model) {
394
- FastImageBlur blur = mBlurRadius > 0 ? new FastImageBlur(mBlurRadius, getScaleType()) : null;
505
+ FastImageBlur blur = mBlurRadius > 0 ? new FastImageBlur(mBlurRadius, mRepeat ? null : getScaleType()) : null;
395
506
  RequestOptions options;
396
- if (mPixelated) {
507
+ if (mRepeat) {
508
+ // Decoded at its own size, or scaled down to fit the view if it's
509
+ // larger (not cropped or fitted for the scale type), and a GIF as
510
+ // its first frame (tiled by setImageDrawable).
511
+ options = new RequestOptions()
512
+ .downsample(new FastImageSourceSize.Capture(
513
+ mPixelated ? DownsampleStrategy.NONE : DownsampleStrategy.CENTER_INSIDE,
514
+ String.valueOf(model)))
515
+ .dontTransform()
516
+ .dontAnimate();
517
+ if (blur != null) options = options.optionalTransform(blur);
518
+ } else if (mPixelated) {
397
519
  // Decoded as it is, then scaled by the view without filtering (see
398
520
  // OwnGifTarget).
399
521
  options = new RequestOptions()
@@ -481,7 +603,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
481
603
  mFadeFrom = null;
482
604
  invalidate();
483
605
  }
484
- reloadIfResized();
606
+ if (!switchSourceIfResized()) reloadIfResized();
485
607
  }
486
608
 
487
609
  private void reloadIfResized() {
@@ -632,16 +754,42 @@ class FastImageViewWithUrl extends AppCompatImageView {
632
754
  @Nonnull FastImageViewManager manager,
633
755
  @Nullable RequestManager requestManager,
634
756
  @Nonnull Map<String, List<FastImageViewWithUrl>> viewsForUrlsMap) {
757
+ mManager = manager;
758
+ mViewsForUrlsMap = viewsForUrlsMap;
635
759
  if (mBlurChanged && !mNeedsReload) {
636
760
  mBlurChanged = false;
637
761
  reblur();
638
762
  }
639
763
  if (!mNeedsReload)
640
764
  return;
765
+ // Several sources: one is picked for the view's size, so a view that
766
+ // hasn't been laid out yet loads after layout (props and layout are
767
+ // applied in the same batch). A view that still has no size then
768
+ // (e.g. one sized from onLoad) loads the largest source.
769
+ if (mSources != null) {
770
+ if (mWaitsForSize) return;
771
+ if ((getWidth() <= 0 || getHeight() <= 0) && !mWaitedForSize) {
772
+ mWaitsForSize = true;
773
+ post(new Runnable() {
774
+ @Override
775
+ public void run() {
776
+ if (!mWaitsForSize) return;
777
+ mWaitsForSize = false;
778
+ mWaitedForSize = true;
779
+ onAfterUpdate(manager, requestManager, viewsForUrlsMap);
780
+ }
781
+ });
782
+ return;
783
+ }
784
+ mWaitedForSize = false;
785
+ pickSource();
786
+ }
641
787
  // Only reload for changes that affect the request (source,
642
788
  // defaultSource, resizeMode), not for every prop update.
643
789
  mNeedsReload = false;
644
790
  mBlurChanged = false;
791
+ boolean switching = mSwitchesSource;
792
+ mSwitchesSource = false;
645
793
  boolean restarting = mRestarting;
646
794
  mRestarting = false;
647
795
  mLoadCount++;
@@ -752,7 +900,7 @@ class FastImageViewWithUrl extends AppCompatImageView {
752
900
  // replaces it at once, as Glide, Coil and Fresco fade an image in
753
901
  // from nothing and never between images.
754
902
  boolean replacing = shownRequest != null && model != null;
755
- if (mTransitionDuration > 0 && (!replacing || mTransitionBetweenImages)) {
903
+ if (mTransitionDuration > 0 && !switching && (!replacing || mTransitionBetweenImages)) {
756
904
  builder = builder.transition(DrawableTransitionOptions.with(
757
905
  new FadeFactory(mTransitionDuration, mTransitionSkipOnCacheHit)));
758
906
  }
@@ -3,6 +3,7 @@ export declare const resizeMode: {
3
3
  readonly cover: 'cover';
4
4
  readonly stretch: 'stretch';
5
5
  readonly center: 'center';
6
+ readonly repeat: 'repeat';
6
7
  };
7
8
  export declare const priority: {
8
9
  readonly low: 'low';
package/dist/index.cjs.js CHANGED
@@ -32,7 +32,8 @@ const resizeMode = {
32
32
  contain: "contain",
33
33
  cover: "cover",
34
34
  stretch: "stretch",
35
- center: "center"
35
+ center: "center",
36
+ repeat: "repeat"
36
37
  };
37
38
  const priority = {
38
39
  low: "low",
@@ -124,7 +125,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
124
125
  };
125
126
  const resolvedTintColor = tintColor != null ? tintColor : tintColorFromStyle(style);
126
127
  if (fallback) {
127
- const cleanedSource = typeof source === "number" ? source : withoutCache(source);
128
+ const cleanedSource = typeof source === "number" ? source : Array.isArray(source) ? source.map(withoutCache) : withoutCache(source);
128
129
  const resolvedSource = react_native.Image.resolveAssetSource(cleanedSource);
129
130
  return /* @__PURE__ */ react.default.createElement(react_native.View, {
130
131
  style: [styles.imageContainer, style],
@@ -163,7 +164,9 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
163
164
  resizeMode
164
165
  }), children);
165
166
  }
166
- const resolvedSource = react_native.Image.resolveAssetSource(source);
167
+ const sources = Array.isArray(source) ? source.length === 1 ? void 0 : source : void 0;
168
+ const single = Array.isArray(source) ? source[0] : source;
169
+ const resolvedSource = sources ? void 0 : react_native.Image.resolveAssetSource(single);
167
170
  const resolvedDefaultSource = resolveDefaultSource(defaultSource);
168
171
  return /* @__PURE__ */ react.default.createElement(react_native.View, {
169
172
  style: [styles.imageContainer, style],
@@ -176,6 +179,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
176
179
  ...transitionProps(transition),
177
180
  style: react_native.StyleSheet.absoluteFill,
178
181
  source: resolvedSource,
182
+ sources,
179
183
  defaultSource: resolvedDefaultSource,
180
184
  onFastImageLoadStart: onLoadStart,
181
185
  onFastImageProgress: withProgress(onProgress),
@@ -199,8 +203,14 @@ const FastImage = FastImageComponent;
199
203
  FastImage.resizeMode = resizeMode;
200
204
  FastImage.cacheControl = cacheControl;
201
205
  FastImage.priority = priority;
202
- FastImage.preload = (sources) => Promise.resolve(react_native.NativeModules.FastImageView.preload(sources.map((s) => s || {}))).then((results) => sources.map((source, i) => {
206
+ const ONE_SOURCE = "Takes one source, not an array of sizes: pass the size to use";
207
+ FastImage.preload = (sources) => Promise.resolve(react_native.NativeModules.FastImageView.preload(sources.map((s) => s && !Array.isArray(s) ? s : {}))).then((results) => sources.map((source, i) => {
203
208
  var _results$i;
209
+ if (Array.isArray(source)) return {
210
+ ok: false,
211
+ error: ONE_SOURCE,
212
+ uri: void 0
213
+ };
204
214
  const uri = source ? source.uri : void 0;
205
215
  const result = (_results$i = results === null || results === void 0 ? void 0 : results[i]) !== null && _results$i !== void 0 ? _results$i : noResult;
206
216
  if (!result.ok) return {
@@ -219,8 +229,14 @@ FastImage.preload = (sources) => Promise.resolve(react_native.NativeModules.Fast
219
229
  FastImage.clearMemoryCache = () => react_native.NativeModules.FastImageView.clearMemoryCache();
220
230
  FastImage.clearDiskCache = () => react_native.NativeModules.FastImageView.clearDiskCache();
221
231
  FastImage.configureCache = (limits = {}) => Promise.resolve(react_native.NativeModules.FastImageView.configureCache(limits));
222
- FastImage.writeToCache = (source, file) => Promise.resolve(react_native.NativeModules.FastImageView.writeToCache(source || {}, file));
223
- FastImage.getCachePath = (source) => Promise.resolve(react_native.NativeModules.FastImageView.getCachePath(source || {}));
232
+ FastImage.writeToCache = (source, file) => Array.isArray(source) ? Promise.resolve({
233
+ ok: false,
234
+ error: ONE_SOURCE
235
+ }) : Promise.resolve(react_native.NativeModules.FastImageView.writeToCache(source || {}, file));
236
+ FastImage.getCachePath = (source) => Array.isArray(source) ? Promise.resolve({
237
+ ok: false,
238
+ error: ONE_SOURCE
239
+ }) : Promise.resolve(react_native.NativeModules.FastImageView.getCachePath(source || {}));
224
240
  const styles = react_native.StyleSheet.create({
225
241
  fallbackImage: {
226
242
  position: "absolute",
@@ -33,6 +33,7 @@ export type ResizeMode = $ReadOnly<{|
33
33
  cover: 'cover',
34
34
  stretch: 'stretch',
35
35
  center: 'center',
36
+ repeat: 'repeat',
36
37
  |}>
37
38
 
38
39
  export type Priority = $ReadOnly<{|
@@ -103,6 +104,9 @@ export type FastImageSource = {
103
104
  cache?: CacheControls,
104
105
  cacheKey?: string,
105
106
  memoryCache?: boolean,
107
+ width?: number,
108
+ height?: number,
109
+ scale?: number,
106
110
  }
107
111
 
108
112
  export type FastImageProps = $ReadOnly<{|
@@ -113,7 +117,7 @@ export type FastImageProps = $ReadOnly<{|
113
117
  onLoadStart?: ?() => void,
114
118
  onProgress?: ?(event: OnProgressEvent) => void,
115
119
 
116
- source?: ?(FastImageSource | number),
120
+ source?: ?(FastImageSource | Array<FastImageSource> | number),
117
121
  defaultSource?: ?number,
118
122
 
119
123
  tintColor?: number | string,
@@ -32,7 +32,8 @@ const resizeMode = {
32
32
  contain: "contain",
33
33
  cover: "cover",
34
34
  stretch: "stretch",
35
- center: "center"
35
+ center: "center",
36
+ repeat: "repeat"
36
37
  };
37
38
  const priority = {
38
39
  low: "low",
@@ -54,14 +55,27 @@ function FastImageBase({ source, defaultSource, tintColor, blurRadius, onLoadSta
54
55
  const result = (0, react.useRef)(void 0);
55
56
  const sent = (0, react.useRef)(Promise.resolve());
56
57
  const image = (0, react.useRef)(null);
57
- const uri = typeof source === "object" && source ? source.uri : void 0;
58
- const webSource = typeof source === "number" ? source : uri ? { uri } : void 0;
58
+ const sizes = Array.isArray(source) && source.length > 1 ? source : void 0;
59
+ const single = Array.isArray(source) ? source[0] : source;
60
+ const uri = typeof single === "object" && single ? single.uri : void 0;
61
+ const webSource = typeof single === "number" ? single : uri ? { uri } : void 0;
59
62
  return /* @__PURE__ */ react.default.createElement(react_native.View, {
60
63
  style: [styles.container, style],
61
64
  onLayout,
62
65
  pointerEvents,
63
66
  ref: forwardedRef
64
- }, /* @__PURE__ */ react.default.createElement(react_native.Image, {
67
+ }, sizes ? /* @__PURE__ */ react.default.createElement(react_native.View, {
68
+ ...props,
69
+ style: styles.image
70
+ }, /* @__PURE__ */ react.default.createElement(ImageSizes, {
71
+ sources: sizes,
72
+ resizeMode: mode,
73
+ blurRadius,
74
+ onLoadStart,
75
+ onLoad,
76
+ onError,
77
+ onLoadEnd
78
+ })) : /* @__PURE__ */ react.default.createElement(react_native.Image, {
65
79
  ...props,
66
80
  style: styles.image,
67
81
  source: webSource,
@@ -107,6 +121,68 @@ function FastImageBase({ source, defaultSource, tintColor, blurRadius, onLoadSta
107
121
  }
108
122
  }), children);
109
123
  }
124
+ function ImageSizes({ sources, resizeMode: mode, blurRadius, onLoadStart, onLoad, onError, onLoadEnd }) {
125
+ var _largest$uri, _sources$find;
126
+ const sized = sources.filter((source) => (source === null || source === void 0 ? void 0 : source.uri) && source.width).map((source) => {
127
+ var _source$width, _source$scale;
128
+ return {
129
+ uri: source.uri,
130
+ width: Math.round(((_source$width = source.width) !== null && _source$width !== void 0 ? _source$width : 0) * ((_source$scale = source.scale) !== null && _source$scale !== void 0 ? _source$scale : 1))
131
+ };
132
+ });
133
+ const largest = sized.reduce((best, source) => !best || source.width > best.width ? source : best, void 0);
134
+ const src = (_largest$uri = largest === null || largest === void 0 ? void 0 : largest.uri) !== null && _largest$uri !== void 0 ? _largest$uri : (_sources$find = sources.find((source) => source === null || source === void 0 ? void 0 : source.uri)) === null || _sources$find === void 0 ? void 0 : _sources$find.uri;
135
+ const srcSet = sized.length > 0 ? sized.map((source) => `${source.uri} ${source.width}w`).join(", ") : void 0;
136
+ (0, react.useEffect)(() => {
137
+ onLoadStart === null || onLoadStart === void 0 || onLoadStart();
138
+ }, [src, srcSet]);
139
+ return /* @__PURE__ */ react.default.createElement("img", {
140
+ src,
141
+ srcSet,
142
+ sizes: srcSet ? "auto, 100vw" : void 0,
143
+ loading: "lazy",
144
+ alt: "",
145
+ draggable: false,
146
+ style: {
147
+ position: "absolute",
148
+ width: "100%",
149
+ height: "100%",
150
+ objectFit: OBJECT_FIT[mode !== null && mode !== void 0 ? mode : "cover"],
151
+ filter: blurRadius ? `blur(${blurRadius}px)` : void 0
152
+ },
153
+ onLoad: (event) => {
154
+ var _picked$width;
155
+ const image = event.currentTarget;
156
+ const picked = srcSet ? sized.find((source) => new URL(source.uri, image.currentSrc).href === image.currentSrc) : void 0;
157
+ const loadedWidth = (_picked$width = picked === null || picked === void 0 ? void 0 : picked.width) !== null && _picked$width !== void 0 ? _picked$width : image.naturalWidth;
158
+ const loadedHeight = picked ? Math.round(image.naturalHeight * picked.width / image.naturalWidth) : image.naturalHeight;
159
+ onLoad === null || onLoad === void 0 || onLoad({ nativeEvent: {
160
+ width: loadedWidth,
161
+ height: loadedHeight
162
+ } });
163
+ onLoadEnd === null || onLoadEnd === void 0 || onLoadEnd({
164
+ ok: true,
165
+ width: loadedWidth,
166
+ height: loadedHeight
167
+ });
168
+ },
169
+ onError: () => {
170
+ const error = "Failed to load the image";
171
+ onError === null || onError === void 0 || onError({ nativeEvent: { error } });
172
+ onLoadEnd === null || onLoadEnd === void 0 || onLoadEnd({
173
+ ok: false,
174
+ error
175
+ });
176
+ }
177
+ });
178
+ }
179
+ const OBJECT_FIT = {
180
+ cover: "cover",
181
+ contain: "contain",
182
+ stretch: "fill",
183
+ center: "scale-down",
184
+ repeat: "cover"
185
+ };
110
186
  const FastImageMemo = (0, react.memo)(FastImageBase);
111
187
  const FastImageComponent = (0, react.forwardRef)((props, ref) => /* @__PURE__ */ react.default.createElement(FastImageMemo, {
112
188
  forwardedRef: ref,
@@ -33,6 +33,7 @@ export type ResizeMode = $ReadOnly<{|
33
33
  cover: 'cover',
34
34
  stretch: 'stretch',
35
35
  center: 'center',
36
+ repeat: 'repeat',
36
37
  |}>
37
38
 
38
39
  export type Priority = $ReadOnly<{|
@@ -103,6 +104,9 @@ export type FastImageSource = {
103
104
  cache?: CacheControls,
104
105
  cacheKey?: string,
105
106
  memoryCache?: boolean,
107
+ width?: number,
108
+ height?: number,
109
+ scale?: number,
106
110
  }
107
111
 
108
112
  export type FastImageProps = $ReadOnly<{|
@@ -113,7 +117,7 @@ export type FastImageProps = $ReadOnly<{|
113
117
  onLoadStart?: ?() => void,
114
118
  onProgress?: ?(event: OnProgressEvent) => void,
115
119
 
116
- source?: ?(FastImageSource | number),
120
+ source?: ?(FastImageSource | Array<FastImageSource> | number),
117
121
  defaultSource?: ?number,
118
122
 
119
123
  tintColor?: number | string,
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { LayoutChangeEvent, StyleProp, ViewStyle, ImageRequireSource, AccessibilityProps, ViewProps } from 'react-native';
3
3
  import { cacheControl, priority, resizeMode } from './constants';
4
4
  type ColorValue = NonNullable<ViewStyle['backgroundColor']>;
5
- export type ResizeMode = 'contain' | 'cover' | 'stretch' | 'center';
5
+ export type ResizeMode = 'contain' | 'cover' | 'stretch' | 'center' | 'repeat';
6
6
  export type Priority = 'low' | 'normal' | 'high';
7
7
  export type Cache = 'immutable' | 'web' | 'cacheOnly';
8
8
  export type Source = {
@@ -26,6 +26,14 @@ export type Source = {
26
26
  * list scrolled back decodes them again.
27
27
  */
28
28
  memoryCache?: boolean;
29
+ /**
30
+ * With several sources (`source` as an array), the image's size at this
31
+ * uri in pixels (width and height, times `scale` if given): the view
32
+ * loads the source closest to its own size.
33
+ */
34
+ width?: number;
35
+ height?: number;
36
+ scale?: number;
29
37
  };
30
38
  export type Transition = {
31
39
  /**
@@ -116,7 +124,12 @@ export interface ImageStyle extends ViewStyle {
116
124
  opacity?: number;
117
125
  }
118
126
  export interface FastImageProps extends AccessibilityProps, ViewProps {
119
- source?: Source | ImageRequireSource;
127
+ /**
128
+ * The image: a source, a `require()`d image, or several sources of the
129
+ * same image at different sizes (each with its `width` and `height` in
130
+ * pixels), of which the view loads the one closest to its size.
131
+ */
132
+ source?: Source | Source[] | ImageRequireSource;
120
133
  defaultSource?: ImageRequireSource;
121
134
  resizeMode?: ResizeMode;
122
135
  fallback?: boolean;
package/dist/index.js CHANGED
@@ -5,7 +5,8 @@ const resizeMode = {
5
5
  contain: "contain",
6
6
  cover: "cover",
7
7
  stretch: "stretch",
8
- center: "center"
8
+ center: "center",
9
+ repeat: "repeat"
9
10
  };
10
11
  const priority = {
11
12
  low: "low",
@@ -97,7 +98,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
97
98
  };
98
99
  const resolvedTintColor = tintColor != null ? tintColor : tintColorFromStyle(style);
99
100
  if (fallback) {
100
- const cleanedSource = typeof source === "number" ? source : withoutCache(source);
101
+ const cleanedSource = typeof source === "number" ? source : Array.isArray(source) ? source.map(withoutCache) : withoutCache(source);
101
102
  const resolvedSource = Image.resolveAssetSource(cleanedSource);
102
103
  return /* @__PURE__ */ React.createElement(View, {
103
104
  style: [styles.imageContainer, style],
@@ -136,7 +137,9 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
136
137
  resizeMode
137
138
  }), children);
138
139
  }
139
- const resolvedSource = Image.resolveAssetSource(source);
140
+ const sources = Array.isArray(source) ? source.length === 1 ? void 0 : source : void 0;
141
+ const single = Array.isArray(source) ? source[0] : source;
142
+ const resolvedSource = sources ? void 0 : Image.resolveAssetSource(single);
140
143
  const resolvedDefaultSource = resolveDefaultSource(defaultSource);
141
144
  return /* @__PURE__ */ React.createElement(View, {
142
145
  style: [styles.imageContainer, style],
@@ -149,6 +152,7 @@ function FastImageBase({ source, defaultSource, tintColor, onLoadStart, onProgre
149
152
  ...transitionProps(transition),
150
153
  style: StyleSheet.absoluteFill,
151
154
  source: resolvedSource,
155
+ sources,
152
156
  defaultSource: resolvedDefaultSource,
153
157
  onFastImageLoadStart: onLoadStart,
154
158
  onFastImageProgress: withProgress(onProgress),
@@ -172,8 +176,14 @@ const FastImage = FastImageComponent;
172
176
  FastImage.resizeMode = resizeMode;
173
177
  FastImage.cacheControl = cacheControl;
174
178
  FastImage.priority = priority;
175
- FastImage.preload = (sources) => Promise.resolve(NativeModules.FastImageView.preload(sources.map((s) => s || {}))).then((results) => sources.map((source, i) => {
179
+ const ONE_SOURCE = "Takes one source, not an array of sizes: pass the size to use";
180
+ FastImage.preload = (sources) => Promise.resolve(NativeModules.FastImageView.preload(sources.map((s) => s && !Array.isArray(s) ? s : {}))).then((results) => sources.map((source, i) => {
176
181
  var _results$i;
182
+ if (Array.isArray(source)) return {
183
+ ok: false,
184
+ error: ONE_SOURCE,
185
+ uri: void 0
186
+ };
177
187
  const uri = source ? source.uri : void 0;
178
188
  const result = (_results$i = results === null || results === void 0 ? void 0 : results[i]) !== null && _results$i !== void 0 ? _results$i : noResult;
179
189
  if (!result.ok) return {
@@ -192,8 +202,14 @@ FastImage.preload = (sources) => Promise.resolve(NativeModules.FastImageView.pre
192
202
  FastImage.clearMemoryCache = () => NativeModules.FastImageView.clearMemoryCache();
193
203
  FastImage.clearDiskCache = () => NativeModules.FastImageView.clearDiskCache();
194
204
  FastImage.configureCache = (limits = {}) => Promise.resolve(NativeModules.FastImageView.configureCache(limits));
195
- FastImage.writeToCache = (source, file) => Promise.resolve(NativeModules.FastImageView.writeToCache(source || {}, file));
196
- FastImage.getCachePath = (source) => Promise.resolve(NativeModules.FastImageView.getCachePath(source || {}));
205
+ FastImage.writeToCache = (source, file) => Array.isArray(source) ? Promise.resolve({
206
+ ok: false,
207
+ error: ONE_SOURCE
208
+ }) : Promise.resolve(NativeModules.FastImageView.writeToCache(source || {}, file));
209
+ FastImage.getCachePath = (source) => Array.isArray(source) ? Promise.resolve({
210
+ ok: false,
211
+ error: ONE_SOURCE
212
+ }) : Promise.resolve(NativeModules.FastImageView.getCachePath(source || {}));
197
213
  const styles = StyleSheet.create({
198
214
  fallbackImage: {
199
215
  position: "absolute",
@@ -33,6 +33,7 @@ export type ResizeMode = $ReadOnly<{|
33
33
  cover: 'cover',
34
34
  stretch: 'stretch',
35
35
  center: 'center',
36
+ repeat: 'repeat',
36
37
  |}>
37
38
 
38
39
  export type Priority = $ReadOnly<{|
@@ -103,6 +104,9 @@ export type FastImageSource = {
103
104
  cache?: CacheControls,
104
105
  cacheKey?: string,
105
106
  memoryCache?: boolean,
107
+ width?: number,
108
+ height?: number,
109
+ scale?: number,
106
110
  }
107
111
 
108
112
  export type FastImageProps = $ReadOnly<{|
@@ -113,7 +117,7 @@ export type FastImageProps = $ReadOnly<{|
113
117
  onLoadStart?: ?() => void,
114
118
  onProgress?: ?(event: OnProgressEvent) => void,
115
119
 
116
- source?: ?(FastImageSource | number),
120
+ source?: ?(FastImageSource | Array<FastImageSource> | number),
117
121
  defaultSource?: ?number,
118
122
 
119
123
  tintColor?: number | string,
package/dist/index.web.js CHANGED
@@ -1,11 +1,12 @@
1
- import React, { forwardRef, memo, useRef } from "react";
1
+ import React, { forwardRef, memo, useEffect, useRef } from "react";
2
2
  import { Image, StyleSheet, View } from "react-native";
3
3
  //#region src/constants.ts
4
4
  const resizeMode = {
5
5
  contain: "contain",
6
6
  cover: "cover",
7
7
  stretch: "stretch",
8
- center: "center"
8
+ center: "center",
9
+ repeat: "repeat"
9
10
  };
10
11
  const priority = {
11
12
  low: "low",
@@ -27,14 +28,27 @@ function FastImageBase({ source, defaultSource, tintColor, blurRadius, onLoadSta
27
28
  const result = useRef(void 0);
28
29
  const sent = useRef(Promise.resolve());
29
30
  const image = useRef(null);
30
- const uri = typeof source === "object" && source ? source.uri : void 0;
31
- const webSource = typeof source === "number" ? source : uri ? { uri } : void 0;
31
+ const sizes = Array.isArray(source) && source.length > 1 ? source : void 0;
32
+ const single = Array.isArray(source) ? source[0] : source;
33
+ const uri = typeof single === "object" && single ? single.uri : void 0;
34
+ const webSource = typeof single === "number" ? single : uri ? { uri } : void 0;
32
35
  return /* @__PURE__ */ React.createElement(View, {
33
36
  style: [styles.container, style],
34
37
  onLayout,
35
38
  pointerEvents,
36
39
  ref: forwardedRef
37
- }, /* @__PURE__ */ React.createElement(Image, {
40
+ }, sizes ? /* @__PURE__ */ React.createElement(View, {
41
+ ...props,
42
+ style: styles.image
43
+ }, /* @__PURE__ */ React.createElement(ImageSizes, {
44
+ sources: sizes,
45
+ resizeMode: mode,
46
+ blurRadius,
47
+ onLoadStart,
48
+ onLoad,
49
+ onError,
50
+ onLoadEnd
51
+ })) : /* @__PURE__ */ React.createElement(Image, {
38
52
  ...props,
39
53
  style: styles.image,
40
54
  source: webSource,
@@ -80,6 +94,68 @@ function FastImageBase({ source, defaultSource, tintColor, blurRadius, onLoadSta
80
94
  }
81
95
  }), children);
82
96
  }
97
+ function ImageSizes({ sources, resizeMode: mode, blurRadius, onLoadStart, onLoad, onError, onLoadEnd }) {
98
+ var _largest$uri, _sources$find;
99
+ const sized = sources.filter((source) => (source === null || source === void 0 ? void 0 : source.uri) && source.width).map((source) => {
100
+ var _source$width, _source$scale;
101
+ return {
102
+ uri: source.uri,
103
+ width: Math.round(((_source$width = source.width) !== null && _source$width !== void 0 ? _source$width : 0) * ((_source$scale = source.scale) !== null && _source$scale !== void 0 ? _source$scale : 1))
104
+ };
105
+ });
106
+ const largest = sized.reduce((best, source) => !best || source.width > best.width ? source : best, void 0);
107
+ const src = (_largest$uri = largest === null || largest === void 0 ? void 0 : largest.uri) !== null && _largest$uri !== void 0 ? _largest$uri : (_sources$find = sources.find((source) => source === null || source === void 0 ? void 0 : source.uri)) === null || _sources$find === void 0 ? void 0 : _sources$find.uri;
108
+ const srcSet = sized.length > 0 ? sized.map((source) => `${source.uri} ${source.width}w`).join(", ") : void 0;
109
+ useEffect(() => {
110
+ onLoadStart === null || onLoadStart === void 0 || onLoadStart();
111
+ }, [src, srcSet]);
112
+ return /* @__PURE__ */ React.createElement("img", {
113
+ src,
114
+ srcSet,
115
+ sizes: srcSet ? "auto, 100vw" : void 0,
116
+ loading: "lazy",
117
+ alt: "",
118
+ draggable: false,
119
+ style: {
120
+ position: "absolute",
121
+ width: "100%",
122
+ height: "100%",
123
+ objectFit: OBJECT_FIT[mode !== null && mode !== void 0 ? mode : "cover"],
124
+ filter: blurRadius ? `blur(${blurRadius}px)` : void 0
125
+ },
126
+ onLoad: (event) => {
127
+ var _picked$width;
128
+ const image = event.currentTarget;
129
+ const picked = srcSet ? sized.find((source) => new URL(source.uri, image.currentSrc).href === image.currentSrc) : void 0;
130
+ const loadedWidth = (_picked$width = picked === null || picked === void 0 ? void 0 : picked.width) !== null && _picked$width !== void 0 ? _picked$width : image.naturalWidth;
131
+ const loadedHeight = picked ? Math.round(image.naturalHeight * picked.width / image.naturalWidth) : image.naturalHeight;
132
+ onLoad === null || onLoad === void 0 || onLoad({ nativeEvent: {
133
+ width: loadedWidth,
134
+ height: loadedHeight
135
+ } });
136
+ onLoadEnd === null || onLoadEnd === void 0 || onLoadEnd({
137
+ ok: true,
138
+ width: loadedWidth,
139
+ height: loadedHeight
140
+ });
141
+ },
142
+ onError: () => {
143
+ const error = "Failed to load the image";
144
+ onError === null || onError === void 0 || onError({ nativeEvent: { error } });
145
+ onLoadEnd === null || onLoadEnd === void 0 || onLoadEnd({
146
+ ok: false,
147
+ error
148
+ });
149
+ }
150
+ });
151
+ }
152
+ const OBJECT_FIT = {
153
+ cover: "cover",
154
+ contain: "contain",
155
+ stretch: "fill",
156
+ center: "scale-down",
157
+ repeat: "cover"
158
+ };
83
159
  const FastImageMemo = memo(FastImageBase);
84
160
  const FastImageComponent = forwardRef((props, ref) => /* @__PURE__ */ React.createElement(FastImageMemo, {
85
161
  forwardedRef: ref,
@@ -33,6 +33,7 @@ export type ResizeMode = $ReadOnly<{|
33
33
  cover: 'cover',
34
34
  stretch: 'stretch',
35
35
  center: 'center',
36
+ repeat: 'repeat',
36
37
  |}>
37
38
 
38
39
  export type Priority = $ReadOnly<{|
@@ -103,6 +104,9 @@ export type FastImageSource = {
103
104
  cache?: CacheControls,
104
105
  cacheKey?: string,
105
106
  memoryCache?: boolean,
107
+ width?: number,
108
+ height?: number,
109
+ scale?: number,
106
110
  }
107
111
 
108
112
  export type FastImageProps = $ReadOnly<{|
@@ -113,7 +117,7 @@ export type FastImageProps = $ReadOnly<{|
113
117
  onLoadStart?: ?() => void,
114
118
  onProgress?: ?(event: OnProgressEvent) => void,
115
119
 
116
- source?: ?(FastImageSource | number),
120
+ source?: ?(FastImageSource | Array<FastImageSource> | number),
117
121
  defaultSource?: ?number,
118
122
 
119
123
  tintColor?: number | string,
@@ -32,6 +32,9 @@ typedef NS_ENUM(NSInteger, FFFCacheControl) {
32
32
  @property (nonatomic) FFFCacheControl cacheControl;
33
33
  // The key to cache the image under instead of its url, or nil.
34
34
  @property (nonatomic, copy) NSString *cacheKey;
35
+ // With several sources, the image's size at this url in pixels (width ×
36
+ // height × scale²), to pick the one closest to the view's size; 0 if unknown.
37
+ @property (nonatomic) CGFloat pixelCount;
35
38
  // Whether the decoded image is kept in the memory cache (`memoryCache`,
36
39
  // default YES). Without it, it's only kept on disk: views don't leave it in
37
40
  // memory, and preload doesn't decode it.
@@ -17,6 +17,9 @@
17
17
  @property (nonatomic, copy) RCTDirectEventBlock onFastImageLoadEnd;
18
18
  @property (nonatomic, assign) RCTResizeMode resizeMode;
19
19
  @property (nonatomic, strong) FFFastImageSource *source;
20
+ // Several sources (2 or more) of the same image at different sizes: the view
21
+ // loads the one whose size is closest to its own.
22
+ @property (nonatomic, copy) NSArray<FFFastImageSource *> *sources;
20
23
  @property (nonatomic, strong) UIImage *defaultSource;
21
24
  @property (nonatomic, strong) UIColor *imageColor;
22
25
  // When it changes, the next image doesn't replace the current one: the view
@@ -24,6 +24,8 @@
24
24
  @property(nonatomic, assign) NSUInteger imageCount;
25
25
  // The view's size when the image showing was blurred.
26
26
  @property(nonatomic, assign) CGSize blurredForSize;
27
+ // The view's size when the image showing was tiled (resizeMode repeat).
28
+ @property(nonatomic, assign) CGSize tiledForSize;
27
29
  // The blurred image showing (untinted), so a tint change can re-show it.
28
30
  @property(nonatomic, strong) UIImage* blurredImage;
29
31
  // The image being blurred fades in once it's blurred (transition).
@@ -39,8 +41,17 @@
39
41
  // Whether the view shows an image that loaded (not defaultSource or nothing).
40
42
  // A new source then keeps it until the new image has loaded (see reloadImage).
41
43
  @property(nonatomic, assign) BOOL showsLoadedImage;
42
- // downsample: a load waits for the view's size (see didSetProps).
44
+ // downsample, or several sources: a load waits for the view's size (see
45
+ // didSetProps).
43
46
  @property(nonatomic, assign) BOOL waitsForSize;
47
+ // The `source` prop. With several `sources`, _source is the one picked for
48
+ // the view's size instead.
49
+ @property(nonatomic, strong) FFFastImageSource* propSource;
50
+ // Several sources: the next load switches to another one for a new view
51
+ // size, and the one after it is loading. It's the same picture at another
52
+ // size, so it doesn't fade in.
53
+ @property(nonatomic, assign) BOOL switchesSource;
54
+ @property(nonatomic, assign) BOOL loadsSwitchedSource;
44
55
  // The size (in pixels) the image showing or loading was decoded for, and
45
56
  // whether it covers it; zero for a full-size image.
46
57
  @property(nonatomic, assign) CGSize decodedBox;
@@ -130,7 +141,8 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
130
141
  // the default), and from the disk cache too with 'all'. Downloads, local
131
142
  // files and bundled images fade.
132
143
  - (BOOL) fadesImageFromCache: (SDImageCacheType)cacheType {
133
- if (self.transitionDuration <= 0 || ([self displaysLoadedImage] && !self.transitionBetweenImages)) {
144
+ if (self.transitionDuration <= 0 || self.loadsSwitchedSource ||
145
+ ([self displaysLoadedImage] && !self.transitionBetweenImages)) {
134
146
  return NO;
135
147
  }
136
148
  NSString* skip = self.transitionSkipOnCacheHit;
@@ -211,11 +223,14 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
211
223
 
212
224
  - (void) setResizeMode: (RCTResizeMode)resizeMode {
213
225
  if (_resizeMode != resizeMode) {
226
+ BOOL repeated = _resizeMode == RCTResizeModeRepeat;
214
227
  _resizeMode = resizeMode;
215
228
  [self updateContentMode];
216
- // The blur is made for the size the image is shown at.
217
- if ([self blurs: self.originalImage]) {
218
- [self setImage: self.originalImage];
229
+ // The blur is made for the size the image is shown at, and repeat
230
+ // shows the image tiled: show it again.
231
+ UIImage* image = self.originalImage ?: super.image;
232
+ if ([self blurs: self.originalImage] || ((repeated || resizeMode == RCTResizeModeRepeat) && image)) {
233
+ [self setImage: image];
219
234
  }
220
235
  }
221
236
  }
@@ -227,7 +242,10 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
227
242
  // view's size.
228
243
  - (void) updateContentMode {
229
244
  UIViewContentMode contentMode = (UIViewContentMode) _resizeMode;
230
- if (_resizeMode == RCTResizeModeCenter) {
245
+ if (_resizeMode == RCTResizeModeRepeat) {
246
+ // The tiled image (see tiledImage:) fills the view.
247
+ contentMode = UIViewContentModeScaleToFill;
248
+ } else if (_resizeMode == RCTResizeModeCenter) {
231
249
  CGSize imageSize = super.image.size;
232
250
  CGSize viewSize = self.bounds.size;
233
251
  if (imageSize.width > viewSize.width || imageSize.height > viewSize.height) {
@@ -243,11 +261,12 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
243
261
  [super layoutSubviews];
244
262
  [self updateContentMode];
245
263
  [self blurAgainIfResized];
264
+ [self tileAgainIfResized];
246
265
  if (self.waitsForSize) {
247
266
  if ([self hasSize]) {
248
267
  [self reloadImage];
249
268
  }
250
- } else {
269
+ } else if (![self switchSourceIfResized]) {
251
270
  [self reloadIfResized];
252
271
  }
253
272
  }
@@ -320,7 +339,8 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
320
339
  [self blurImage: image];
321
340
  return;
322
341
  }
323
- self.originalImage = image && self.imageColor ? image : nil;
342
+ // Kept to tint, or tile (resizeMode repeat), it again.
343
+ self.originalImage = image && (self.imageColor || _resizeMode == RCTResizeModeRepeat) ? image : nil;
324
344
  self.blurredImage = nil;
325
345
  self.blursOverNothing = NO;
326
346
  // The blur was removed before it was ready: the fade it was waiting for
@@ -342,7 +362,9 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
342
362
  // template is), or, before SDWebImage 5.20, the first frame shows.
343
363
  - (void) showImage: (UIImage*)image {
344
364
  UIColor* tint = image ? self.imageColor : nil;
345
- BOOL animated = [image conformsToProtocol: @protocol(SDAnimatedImage)] && [(id<SDAnimatedImage>)image animatedImageFrameCount] > 1;
365
+ // resizeMode repeat tiles an animated image's first frame (tiledImage:).
366
+ BOOL repeats = image && _resizeMode == RCTResizeModeRepeat;
367
+ BOOL animated = !repeats && [image conformsToProtocol: @protocol(SDAnimatedImage)] && [(id<SDAnimatedImage>)image animatedImageFrameCount] > 1;
346
368
  self.tintColor = tint;
347
369
  if ([self tintsFrames]) {
348
370
  [self setValue: tint && animated ? [SDImageTintTransformer transformerWithColor: tint] : nil forKey: @"animationTransformer"];
@@ -356,17 +378,54 @@ static UIImage* FFFBlurredImage(UIImage* image, CGFloat scale, CGFloat radius, B
356
378
  if (changed) {
357
379
  [self.player seekToFrameAtIndex: 0 loopCount: 0];
358
380
  }
359
- } else if (tint) {
360
- if (animated) {
361
- image = [UIImage imageWithCGImage: image.CGImage scale: image.scale orientation: image.imageOrientation];
362
- }
363
- super.image = [image imageWithRenderingMode: UIImageRenderingModeAlwaysTemplate];
364
381
  } else {
382
+ if (tint) {
383
+ if (animated) {
384
+ image = [UIImage imageWithCGImage: image.CGImage scale: image.scale orientation: image.imageOrientation];
385
+ }
386
+ image = [image imageWithRenderingMode: UIImageRenderingModeAlwaysTemplate];
387
+ }
388
+ if (repeats) {
389
+ image = [self tiledImage: image];
390
+ self.tiledForSize = self.bounds.size;
391
+ }
365
392
  super.image = image;
366
393
  }
367
394
  [self updateContentMode];
368
395
  }
369
396
 
397
+ // resizeMode repeat: the image repeated from the top-left at its own size in
398
+ // pixels (a scaled image, e.g. a bundled @3x one, at its size in points), an
399
+ // animated image's first frame, scaled down to fit the view if it's larger,
400
+ // as React Native's Image does (and Android here).
401
+ - (UIImage*) tiledImage: (UIImage*)image {
402
+ CGImageRef cgImage = image.CGImage;
403
+ if (!cgImage) {
404
+ return image;
405
+ }
406
+ CGFloat scale = image.scale > 1 ? image.scale : (self.window.screen.scale ?: [UIScreen mainScreen].scale);
407
+ CGSize size = CGSizeMake(image.size.width * image.scale / scale, image.size.height * image.scale / scale);
408
+ CGSize view = self.bounds.size;
409
+ if (view.width > 0 && view.height > 0 && (size.width > view.width || size.height > view.height)) {
410
+ scale *= MAX(size.width / view.width, size.height / view.height);
411
+ }
412
+ UIImage* still = [[UIImage imageWithCGImage: cgImage scale: scale orientation: image.imageOrientation]
413
+ imageWithRenderingMode: image.renderingMode];
414
+ return [still resizableImageWithCapInsets: UIEdgeInsetsZero resizingMode: UIImageResizingModeTile];
415
+ }
416
+
417
+ // The view's size changed since the image was tiled, which scales an image
418
+ // larger than the view down to fit it: tile it again.
419
+ - (void) tileAgainIfResized {
420
+ if (_resizeMode != RCTResizeModeRepeat || CGSizeEqualToSize(self.bounds.size, self.tiledForSize)) {
421
+ return;
422
+ }
423
+ UIImage* image = [self blurs: self.originalImage] ? self.blurredImage : self.originalImage;
424
+ if (image) {
425
+ [self showImage: image];
426
+ }
427
+ }
428
+
370
429
  - (void) setBlurRadius: (CGFloat)blurRadius {
371
430
  if (_blurRadius == blurRadius) {
372
431
  return;
@@ -594,12 +653,59 @@ NSString *FFFErrorMessage(NSError *error)
594
653
  }
595
654
 
596
655
  - (void) setSource: (FFFastImageSource*)source {
597
- if (_source != source) {
598
- _source = source;
656
+ if (self.propSource != source) {
657
+ self.propSource = source;
658
+ if (![self picksSource]) {
659
+ _source = source;
660
+ }
661
+ _needsReload = YES;
662
+ }
663
+ }
664
+
665
+ - (void) setSources: (NSArray<FFFastImageSource*>*)sources {
666
+ if (_sources != sources) {
667
+ _sources = [sources copy];
668
+ if (![self picksSource]) {
669
+ _source = self.propSource;
670
+ }
599
671
  _needsReload = YES;
600
672
  }
601
673
  }
602
674
 
675
+ // Whether the view picks one of several sources for its size.
676
+ - (BOOL) picksSource {
677
+ return _sources.count > 1;
678
+ }
679
+
680
+ // Of several sources, the one whose size in pixels is closest to the view's
681
+ // (by pixel count), or the largest while the view has no size (e.g. one sized
682
+ // from onLoad).
683
+ - (FFFastImageSource*) sourceForSize {
684
+ CGFloat scale = self.window.screen.scale ?: [UIScreen mainScreen].scale;
685
+ CGFloat viewPixels = self.bounds.size.width * self.bounds.size.height * scale * scale;
686
+ FFFastImageSource* best = nil;
687
+ CGFloat bestFit = CGFLOAT_MAX;
688
+ for (FFFastImageSource* source in _sources) {
689
+ CGFloat fit = viewPixels > 0 ? ABS(1 - source.pixelCount / viewPixels) : -source.pixelCount;
690
+ if (!best || fit < bestFit) {
691
+ best = source;
692
+ bestFit = fit;
693
+ }
694
+ }
695
+ return best;
696
+ }
697
+
698
+ // Several sources: another one fits the view's new size better. Loads it,
699
+ // keeping the image showing until then, without a fade (see switchesSource).
700
+ - (BOOL) switchSourceIfResized {
701
+ if (![self picksSource] || _needsReload || ![self hasSize] || [self sourceForSize] == _source) {
702
+ return NO;
703
+ }
704
+ self.switchesSource = YES;
705
+ [self reloadImage];
706
+ return YES;
707
+ }
708
+
603
709
  - (void) setRecyclingKey: (NSString*)recyclingKey {
604
710
  if (_recyclingKey == recyclingKey || [_recyclingKey isEqualToString: recyclingKey]) {
605
711
  return;
@@ -625,12 +731,13 @@ NSString *FFFErrorMessage(NSError *error)
625
731
 
626
732
  - (void) didSetProps: (NSArray<NSString*>*)changedProps {
627
733
  if (_needsReload) {
628
- // With downsample on, the image is decoded for the view's size, so
629
- // a view that hasn't been laid out yet loads once it has. Props and
630
- // layout are applied in the same update, so that's before the next
631
- // frame (in layoutSubviews). A view that still has no size then
632
- // (e.g. one sized from onLoad) loads at full size.
633
- if ([self downsamples] && ![self hasSize]) {
734
+ // With downsample on, the image is decoded for the view's size, and
735
+ // with several sources one is picked for it, so a view that hasn't
736
+ // been laid out yet loads once it has. Props and layout are applied
737
+ // in the same update, so that's before the next frame (in
738
+ // layoutSubviews). A view that still has no size then (e.g. one sized
739
+ // from onLoad) loads at full size, or the largest source.
740
+ if (([self downsamples] || [self picksSource]) && ![self hasSize]) {
634
741
  if (!self.waitsForSize) {
635
742
  self.waitsForSize = YES;
636
743
  __weak typeof(self) weakSelf = self;
@@ -717,6 +824,11 @@ NSString *FFFErrorMessage(NSError *error)
717
824
  - (void) reloadImage {
718
825
  _needsReload = NO;
719
826
  self.waitsForSize = NO;
827
+ self.loadsSwitchedSource = self.switchesSource;
828
+ self.switchesSource = NO;
829
+ if ([self picksSource]) {
830
+ _source = [self sourceForSize];
831
+ }
720
832
  self.decodedBox = CGSizeZero;
721
833
  // The previous load, if it's still running, is for a source the view no
722
834
  // longer shows: cancel it (a new download would, but a data uri or no
@@ -101,6 +101,7 @@ RCT_EXPORT_MODULE(FastImageView)
101
101
  }
102
102
 
103
103
  RCT_EXPORT_VIEW_PROPERTY(source, FFFastImageSource)
104
+ RCT_EXPORT_VIEW_PROPERTY(sources, FFFastImageSourceArray)
104
105
  RCT_EXPORT_VIEW_PROPERTY(defaultSource, UIImage)
105
106
  RCT_EXPORT_VIEW_PROPERTY(resizeMode, RCTResizeMode)
106
107
  RCT_EXPORT_VIEW_PROPERTY(recyclingKey, NSString)
@@ -5,5 +5,6 @@
5
5
  @interface RCTConvert (FFFastImage)
6
6
 
7
7
  + (FFFastImageSource *)FFFastImageSource:(id)json;
8
+ + (NSArray<FFFastImageSource *> *)FFFastImageSourceArray:(id)json;
8
9
 
9
10
  @end
@@ -51,6 +51,8 @@ RCT_ENUM_CONVERTER(FFFCacheControl, (@{
51
51
  imageSource.cacheKey = cacheKey.length > 0 ? cacheKey : nil;
52
52
  id memoryCache = json[@"memoryCache"];
53
53
  imageSource.memoryCache = memoryCache && memoryCache != (id)kCFNull ? [self BOOL:memoryCache] : YES;
54
+ CGFloat scale = json[@"scale"] && json[@"scale"] != (id)kCFNull ? [self CGFloat:json[@"scale"]] : 1;
55
+ imageSource.pixelCount = [self CGFloat:json[@"width"]] * [self CGFloat:json[@"height"]] * scale * scale;
54
56
 
55
57
  return imageSource;
56
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-fast-image",
3
- "version": "8.24.0",
3
+ "version": "8.26.0",
4
4
  "description": "🚩 FastImage, performant React Native image component.",
5
5
  "keywords": [
6
6
  "cache",