react-native-fast-image 10.1.1 → 10.3.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.
Files changed (35) hide show
  1. package/README.md +107 -14
  2. package/android/src/main/java/com/fastimage/FastImageBlur.java +3 -2
  3. package/android/src/main/java/com/fastimage/FastImageThumbHash.java +246 -0
  4. package/android/src/main/java/com/fastimage/FastImageViewConverter.java +1 -1
  5. package/android/src/main/java/com/fastimage/FastImageViewManager.java +20 -5
  6. package/android/src/main/java/com/fastimage/FastImageViewWithUrl.java +125 -48
  7. package/dist/index.cjs.js +47 -9
  8. package/dist/index.cjs.js.flow +11 -0
  9. package/dist/index.cjs.web.js +67 -14
  10. package/dist/index.cjs.web.js.flow +11 -0
  11. package/dist/index.d.ts +77 -13
  12. package/dist/index.js +48 -10
  13. package/dist/index.js.flow +11 -0
  14. package/dist/index.web.js +68 -15
  15. package/dist/index.web.js.flow +11 -0
  16. package/dist/placeholders.d.ts +4 -0
  17. package/dist/specs/FastImageViewNativeComponent.d.ts +2 -1
  18. package/dist/thumbhash.cjs.js +241 -0
  19. package/dist/thumbhash.d.ts +12 -0
  20. package/dist/thumbhash.js +240 -0
  21. package/ios/FastImage/FFFBoxBlur.h +7 -0
  22. package/ios/FastImage/FFFBoxBlur.m +29 -0
  23. package/ios/FastImage/FFFThumbHash.h +24 -0
  24. package/ios/FastImage/FFFThumbHash.m +269 -0
  25. package/ios/FastImage/FFFastImageView.h +7 -1
  26. package/ios/FastImage/FFFastImageView.m +93 -46
  27. package/ios/FastImage/FFFastImageViewComponentView.mm +11 -4
  28. package/package.json +4 -2
  29. package/src/index.js.flow +11 -0
  30. package/src/index.tsx +166 -31
  31. package/src/index.web.tsx +166 -33
  32. package/src/placeholders.ts +45 -0
  33. package/src/specs/FastImageViewNativeComponent.ts +8 -2
  34. package/src/thumbhash.ts +363 -0
  35. package/thumbhash/package.json +8 -0
package/README.md CHANGED
@@ -26,6 +26,7 @@ React Native's `Image` component handles image caching like browsers for the mos
26
26
  - **Responsive images**: give several sizes, and the one that fits the view loads ([`source`](#source)).
27
27
  - **Animated images** (GIF, WebP, APNG and AVIF) that can [loop](#loop) and [pause](#paused).
28
28
  - **Fades** in and between images ([`transition`](#transition)).
29
+ - **Placeholders**: a blurred preview from a [ThumbHash](#sourcethumbhash) or a [tiny image](#sourceblurdatauri) while an image loads, or a bundled image ([`placeholder`](#placeholder)).
29
30
  - [**SVG**](docs/formats.md#svg-images) and [**photo library**](#photo-library-images-ios-and-tvos) images.
30
31
  - [**`FastImageBackground`**](#fastimagebackground) for content over an image.
31
32
  - **Expo and web support**, with a [config plugin](#expo-config-plugin) for Expo.
@@ -70,7 +71,7 @@ npx expo install react-native-fast-image
70
71
 
71
72
  It has native code, so it needs a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (e.g. `npx expo run:ios`), not Expo Go.
72
73
 
73
- Needs React Native 0.83 or later with the New Architecture (Expo SDK 55 or later), iOS/tvOS 15.1 or later, and Android 7.0 (API 24) or later; on tvOS, it works with [React Native tvOS](https://github.com/react-native-tvos/react-native-tvos). Apps on the legacy architecture or older React Native can use FastImage 9 (`npm install react-native-fast-image@9`). Upgrading from FastImage 9: see [Upgrading from 9.x](docs/upgrading.md).
74
+ Needs React Native 0.83 or later with the New Architecture (Expo SDK 55 or later) and React 19, iOS/tvOS 15.1 or later, and Android 7.0 (API 24) or later; on tvOS, it works with [React Native tvOS](https://github.com/react-native-tvos/react-native-tvos). Apps on the legacy architecture or older React Native can use FastImage 9 (`npm install react-native-fast-image@9`). Upgrading from FastImage 9: see [Upgrading from 9.x](docs/upgrading.md).
74
75
 
75
76
  To set the cache's size when the app starts, see [Native config](#native-config).
76
77
 
@@ -160,6 +161,8 @@ A hint for which images to start loading first when several are waiting: `'high'
160
161
  - `'normal'`: the default.
161
162
  - `'high'`: e.g. the image the screen is about.
162
163
 
164
+ On the web, it's the image's `fetchpriority`.
165
+
163
166
  ---
164
167
 
165
168
  ##### `source.cache`
@@ -211,6 +214,32 @@ Whether the image is kept on disk. With `false` it isn't kept in the disk cache
211
214
 
212
215
  ---
213
216
 
217
+ ##### `source.thumbhash`
218
+
219
+ **Type:** `string`
220
+
221
+ A [ThumbHash](https://evanw.github.io/thumbhash/) of the image, shown blurred while the image loads, and if it fails to load. It's a short string you make when the image is uploaded (e.g. with the `thumbhash` package) and send with the image's url.
222
+
223
+ On the web, it needs `import 'react-native-fast-image/thumbhash'` (see [ThumbHashes on the web](#thumbhashes-on-the-web)).
224
+
225
+ ```jsx
226
+ <FastImage source={{ uri: photo.url, thumbhash: photo.thumbhash }} />
227
+ ```
228
+
229
+ ---
230
+
231
+ ##### `source.blurDataUri`
232
+
233
+ **Type:** `string`
234
+
235
+ A tiny version of the image (e.g. 8 to 20 px) as a data uri, shown blurred while the image loads, and if it fails to load. On the web it shows without any JavaScript, e.g. in a server-rendered page.
236
+
237
+ ```jsx
238
+ <FastImage source={{ uri: photo.url, blurDataUri: photo.lqip }} />
239
+ ```
240
+
241
+ ---
242
+
214
243
  ##### `source.width`
215
244
 
216
245
  **Type:** `number`
@@ -235,13 +264,32 @@ With several sources, the scale `width` and `height` are multiplied by.
235
264
 
236
265
  ---
237
266
 
267
+ #### `placeholder`
268
+
269
+ **Type:** `string | number | SourcePlaceholder`
270
+
271
+ A placeholder shown while the image loads, and if it fails to load:
272
+
273
+ - An image loaded with `require()`: `placeholder={require('./gray.png')}`.
274
+ - A data uri: `placeholder="data:image/png;base64,…"`.
275
+ - The name of an image bundled with the app (an iOS asset catalog image or an Android drawable): `placeholder="gray"`.
276
+ - A [ThumbHash](#sourcethumbhash), shown blurred: `placeholder={{ thumbhash }}`.
277
+ - A tiny image as a data uri, shown blurred: `placeholder={{ blurDataUri }}`.
278
+
279
+ A source's own `thumbhash` or `blurDataUri` is shown instead, if it has one.
280
+
281
+ Notes:
282
+
283
+ - A url isn't loaded: it's ignored, with a warning in development.
284
+ - On Android, a `require()`d image doesn't show in debug builds, where the dev server serves it. Release builds show it.
285
+
286
+ ---
287
+
238
288
  #### `defaultSource`
239
289
 
240
290
  **Type:** `number`
241
291
 
242
- An asset loaded with `require(...)`, shown while the first image loads, and if an image fails to load. When `source` changes, the previous image shows while the new one loads instead (see `source` and `recyclingKey`).
243
-
244
- On Android, `defaultSource` doesn't show in debug builds: there the dev server serves `require()`d images, and `defaultSource` is only loaded from the app's resources.
292
+ **Deprecated:** Use `placeholder`, which does the same.
245
293
 
246
294
  ---
247
295
 
@@ -272,7 +320,7 @@ Where the image is in the view, as CSS's `object-position` places it: where its
272
320
  - A string in CSS's syntax: one or two keywords (`'top'`, `'left bottom'`), lengths in points (`'10px 20px'`, with `px`) or percentages (`'25% 75%'`), or offsets from an edge (`'right 10px bottom 20%'`). A percentage puts that point of the image at that point of the view: `'0% 0%'` is the top-left corner, `'100% 100%'` the bottom-right.
273
321
  - An object with one of `top` and `bottom` and one of `left` and `right`, each in points (a number) or a percentage (`'25%'`), e.g. `{ right: 10, bottom: '20%' }`. A missing axis is centered.
274
322
 
275
- Points are dp on Android. An invalid value is ignored (the image is centered), with a warning in development. `defaultSource` is placed the same way.
323
+ Points are dp on Android. An invalid value is ignored (the image is centered), with a warning in development. Placeholders are placed the same way.
276
324
 
277
325
  ```jsx
278
326
  <FastImage source={{ uri }} objectFit="cover" objectPosition="top" />
@@ -284,7 +332,7 @@ Points are dp on Android. An invalid value is ignored (the image is centered), w
284
332
 
285
333
  **Type:** `string | null`
286
334
 
287
- For views that get reused for other content, such as rows in FlashList or recyclerlistview. Set it to something that identifies the content, e.g. the item's id. When it changes, the image is cleared right away (to `defaultSource`, or blank) instead of staying until the new one has loaded. Unlike changing `key`, this keeps the view, which is what list recycling is for.
335
+ For views that get reused for other content, such as rows in FlashList or recyclerlistview. Set it to something that identifies the content, e.g. the item's id. When it changes, the image is cleared right away (to `placeholder`, or blank) instead of staying until the new one has loaded. Unlike changing `key`, this keeps the view, which is what list recycling is for.
288
336
 
289
337
  ```jsx
290
338
  <FastImage recyclingKey={item.id} source={{ uri: item.imageUrl }} />
@@ -319,6 +367,22 @@ How the image is filtered when it's drawn smaller or larger than its size (like
319
367
 
320
368
  ---
321
369
 
370
+ #### `loading`
371
+
372
+ **Type:** `'lazy' | 'eager'` · **Default:** `'lazy'` · Web only
373
+
374
+ Whether the image loads as it nears the screen (`'lazy'`) or at once (`'eager'`).
375
+
376
+ ---
377
+
378
+ #### `sizes`
379
+
380
+ **Type:** `string` · **Default:** `'auto, 100vw'` · Web only
381
+
382
+ With several sources, the width the image is shown at, as the image's [`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#sizes), so the browser loads the size that fits. The default is the width the image is laid out at, which needs `loading="lazy"` (otherwise, or in browsers without it, the window's width).
383
+
384
+ ---
385
+
322
386
  #### `paused`
323
387
 
324
388
  **Type:** `boolean`
@@ -368,7 +432,7 @@ Blurs the image by this radius, in points (the same radius looks about the same
368
432
 
369
433
  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.
370
434
 
371
- - Only the loaded image is blurred, not `defaultSource`.
435
+ - Only the loaded image is blurred, not a placeholder.
372
436
  - An animated image (GIF, animated WebP, APNG or AVIF) shows its first frame, blurred, and doesn't animate.
373
437
  - With `tintColor`, the blurred image is tinted.
374
438
  - 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.
@@ -452,7 +516,7 @@ The image's style: View's style props (`borderRadius` clips the image, and borde
452
516
 
453
517
  **Type:** `ColorValue`
454
518
 
455
- If supplied, changes the color of all the non-transparent pixels to the given color.
519
+ If supplied, changes the color of all the non-transparent pixels to the given color, including the placeholder.
456
520
 
457
521
  <!-- api:props end -->
458
522
 
@@ -811,12 +875,20 @@ An image's load priority: see [`source.priority`](#sourcepriority).
811
875
 
812
876
  ---
813
877
 
878
+ ### `SourcePlaceholder`
879
+
880
+ **Type:** `Pick<Source, 'thumbhash' | 'blurDataUri'>`
881
+
882
+ A `thumbhash` or `blurDataUri` as `placeholder`.
883
+
884
+ ---
885
+
814
886
  ### `Transition`
815
887
 
816
888
  How the image fades in: see [`transition`](#transition).
817
889
 
818
890
  - `duration?` (`number`): How long the fade takes, in milliseconds; 0 means no fade. Defaults to the default transition's (see `FastImage.setDefaults`) if it fades, otherwise the platform's usual length: 300 ms on Android, 250 ms on iOS.
819
- - `betweenImages?` (`boolean`, default `false`): Also fades between images: a new `source` replacing an image that's showing cross-dissolves from it. Otherwise only an image that appears over nothing (or over `defaultSource`) fades in, and a new source replaces the image showing at once, once it has loaded.
891
+ - `betweenImages?` (`boolean`, default `false`): Also fades between images: a new `source` replacing an image that's showing cross-dissolves from it. Otherwise only an image that appears over nothing (or over a placeholder) fades in, and a new source replaces the image showing at once, once it has loaded.
820
892
  - `skipOnCacheHit?` (`'none' | 'memory' | 'all' | null`, default `'memory'`): Skips the fade for an image from a cache, so images already loaded show at once, e.g. in a list scrolled back up or a reused row.
821
893
 
822
894
  - `'memory'`: skips it for images from the memory cache.
@@ -878,16 +950,37 @@ The app needs access to the photo library, which it has if it got the url from t
878
950
 
879
951
  ## Web
880
952
 
881
- FastImage works on the web with [react-native-web](https://necolas.github.io/react-native-web/). Bundlers pick the web version through the package's `browser` field, or the `.web.js` files next to the native ones.
953
+ FastImage works on the web with [react-native-web](https://necolas.github.io/react-native-web/) and React 19 or later. Bundlers pick the web version through the package's `browser` field, or the `.web.js` files next to the native ones.
882
954
 
883
955
  Support is minimal:
884
956
 
885
- - Props that work: `source` (a `uri`, a `require()`d image, or several sizes), `defaultSource`, `objectFit`, `objectPosition`, `tintColor`, `blurRadius`, `style`, `onLoadStart`, `onLoad`, `onError`, `onLoadEnd`, and View props such as `testID`, accessibility props, `onLayout` and `pointerEvents`.
886
- - Ignored: `source.headers` (a browser can't send them for an image), `source.priority`, `source.cache`, `source.cacheKey`, `source.memoryCache`, `source.diskCache`, `recyclingKey`, `loop`, `imageRendering`, `paused`, `transition`, `downsample` and `onProgress`.
887
- - The image is an `<img>` element that fills the view inside its borders, with `objectFit` and `objectPosition` as its CSS `object-fit` and `object-position`. `tintColor` is an SVG filter and `blurRadius` a CSS `blur()`, and `defaultSource` is another `<img>`, under the image until it has loaded. A new `source` gets a new `<img>`, so the previous image doesn't stay while it loads (with several sizes, the browser shows it until the new one has loaded).
888
- - 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.
957
+ - Props that work: `source` (a `uri`, a `require()`d image, or several sizes, with `thumbhash` and `blurDataUri`), `placeholder` (and `defaultSource`), `objectFit`, `objectPosition`, `tintColor`, `blurRadius`, `style`, `source.priority`, the web-only `loading` and `sizes`, `onLoadStart`, `onLoad`, `onError`, `onLoadEnd`, and View props such as `testID`, accessibility props, `onLayout` and `pointerEvents`.
958
+ - Ignored: `source.headers` (a browser can't send them for an image), `source.cache`, `source.cacheKey`, `source.memoryCache`, `source.diskCache`, `recyclingKey`, `loop`, `imageRendering`, `paused`, `transition`, `downsample` and `onProgress`.
959
+ - The image is an `<img>` element that fills the view inside its borders, with `objectFit` and `objectPosition` as its CSS `object-fit` and `object-position`. `tintColor` is an SVG filter and `blurRadius` a CSS `blur()`, and the placeholder is another `<img>`, under the image until it has loaded. A new `source` gets a new `<img>`, so the previous image doesn't stay while it loads (with several sizes, the browser shows it until the new one has loaded).
960
+ - Images load lazily unless [`loading`](#loading) is `'eager'`, and `source.priority` `'high'` and `'low'` are the `<img>`'s `fetchpriority`.
961
+ - Several sizes are shown with an `<img>` whose `srcset` lists them, with `sizes="auto, 100vw"` (or [`sizes`](#sizes)): 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"`, or an image with `loading="eager"`, use the viewport's width.
889
962
  - `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' }`.
890
963
 
964
+ ### ThumbHashes on the web
965
+
966
+ On the web, a [`thumbhash`](#sourcethumbhash) needs a decoder, which isn't included by default. Import it once:
967
+
968
+ ```js
969
+ import 'react-native-fast-image/thumbhash'
970
+ ```
971
+
972
+ Or, to show the preview without any JavaScript (e.g. in a server-rendered page), make the hash a [`blurDataUri`](#sourceblurdatauri) on your server:
973
+
974
+ ```js
975
+ import { thumbHashToBlurDataUri } from 'react-native-fast-image/thumbhash'
976
+
977
+ const source = {
978
+ uri: photo.url,
979
+ thumbhash: photo.thumbhash, // iOS and Android
980
+ blurDataUri: thumbHashToBlurDataUri(photo.thumbhash), // the web
981
+ }
982
+ ```
983
+
891
984
  ## Jest
892
985
 
893
986
  With React Native's Jest preset, FastImage renders without any setup, as its native view (`FastImageView`, in snapshots). Its functions need the native module, which Jest doesn't have, so they throw: mock the ones a test calls, e.g.
@@ -136,8 +136,9 @@ final class FastImageBlur extends BitmapTransformation {
136
136
  pool.put(copy);
137
137
  }
138
138
 
139
- // Three box blurs, each across then down, with the edges extended.
140
- private static void blur(int[] pixels, int width, int height, int boxSize) {
139
+ // Three box blurs, each across then down, with the edges extended. Also
140
+ // for a source's blurDataUri (FastImageThumbHash).
141
+ static void blur(int[] pixels, int width, int height, int boxSize) {
141
142
  int[] other = new int[width * height];
142
143
  int radius = boxSize / 2;
143
144
  for (int i = 0; i < 3; i++) {
@@ -0,0 +1,246 @@
1
+ package com.fastimage;
2
+
3
+ import android.graphics.Bitmap;
4
+ import android.graphics.BitmapFactory;
5
+ import android.util.Base64;
6
+ import android.util.LruCache;
7
+
8
+ import androidx.annotation.Nullable;
9
+
10
+ import java.nio.IntBuffer;
11
+ import java.util.Locale;
12
+
13
+ // A source's `thumbhash` (https://evanw.github.io/thumbhash/) decoded into the
14
+ // placeholder it shows while its image loads: at most 32 × 32 pixels, which
15
+ // the view scales up smoothly. Decoding takes well under a millisecond, so it
16
+ // happens as the view picks its source; recent ones are cached by their hash,
17
+ // for list rows that show the same images again. Also a source's
18
+ // `blurDataUri`: a data uri's few pixels, scaled up to the same size and
19
+ // blurred (blurDataUri).
20
+ //
21
+ // thumbHashToRGBA is Evan Wallace's reference decoder
22
+ // (https://github.com/evanw/thumbhash, java/com/madebyevan/thumbhash), with a
23
+ // check that the hash is long enough for what its header says it holds:
24
+ //
25
+ // Copyright (c) 2023 Evan Wallace
26
+ //
27
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ // of this software and associated documentation files (the "Software"), to
29
+ // deal in the Software without restriction, including without limitation the
30
+ // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
31
+ // sell copies of the Software, and to permit persons to whom the Software is
32
+ // furnished to do so, subject to the following conditions:
33
+ //
34
+ // The above copyright notice and this permission notice shall be included in
35
+ // all copies or substantial portions of the Software.
36
+ //
37
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
42
+ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
43
+ // IN THE SOFTWARE.
44
+ final class FastImageThumbHash {
45
+ private FastImageThumbHash() {
46
+ }
47
+
48
+ // Recently decoded placeholders, by their hash or blurDataUri: at most
49
+ // 4 KB each.
50
+ private static final LruCache<String, Bitmap> cache = new LruCache<>(256);
51
+
52
+ // A source's placeholder: its blurDataUri's, or its ThumbHash's.
53
+ @Nullable
54
+ static Bitmap placeholder(@Nullable String value) {
55
+ return value != null && isDataUri(value) ? blurDataUri(value) : bitmap(value);
56
+ }
57
+
58
+ // A source's blurDataUri, decoded, scaled so its longer side is 32 px
59
+ // (a ThumbHash's size), and blurred by about half of one of its pixels, so
60
+ // that a few pixels scale up smoothly instead of in steps.
61
+ @Nullable
62
+ static Bitmap blurDataUri(String uri) {
63
+ Bitmap cached = cache.get(uri);
64
+ if (cached != null) return cached;
65
+ Bitmap decoded = dataUri(uri);
66
+ if (decoded == null) return null;
67
+ int width = decoded.getWidth();
68
+ int height = decoded.getHeight();
69
+ float scale = 32f / Math.max(width, height);
70
+ int w = Math.max(1, Math.round(width * scale));
71
+ int h = Math.max(1, Math.round(height * scale));
72
+ // Blurred on its premultiplied pixels (as stored), so transparent
73
+ // pixels don't darken their neighbors.
74
+ Bitmap bitmap = Bitmap.createScaledBitmap(decoded, w, h, true).copy(Bitmap.Config.ARGB_8888, true);
75
+ if (bitmap == null) return null;
76
+ int[] pixels = new int[w * h];
77
+ IntBuffer buffer = IntBuffer.wrap(pixels);
78
+ bitmap.copyPixelsToBuffer(buffer);
79
+ FastImageBlur.blur(pixels, w, h, 2 * Math.max(1, Math.round(scale / 2)) + 1);
80
+ buffer.rewind();
81
+ bitmap.copyPixelsFromBuffer(buffer);
82
+ cache.put(uri, bitmap);
83
+ return bitmap;
84
+ }
85
+
86
+ static boolean isDataUri(String value) {
87
+ return value.regionMatches(true, 0, "data:", 0, 5);
88
+ }
89
+
90
+ // A base64 data uri's image (PNG, JPEG, WebP, a GIF's first frame), or
91
+ // null if it isn't one Android decodes (e.g. an SVG).
92
+ @Nullable
93
+ static Bitmap dataUri(String uri) {
94
+ int comma = uri.indexOf(',');
95
+ if (comma < 0 || !uri.substring(0, comma).toLowerCase(Locale.ROOT).endsWith(";base64")) return null;
96
+ byte[] data;
97
+ try {
98
+ data = Base64.decode(uri.substring(comma + 1), Base64.DEFAULT);
99
+ } catch (IllegalArgumentException e) {
100
+ return null;
101
+ }
102
+ return BitmapFactory.decodeByteArray(data, 0, data.length);
103
+ }
104
+
105
+ // The placeholder for a base64 ThumbHash (standard or url-safe, with or
106
+ // without padding), or null for an empty or invalid one.
107
+ @Nullable
108
+ static Bitmap bitmap(@Nullable String thumbhash) {
109
+ if (thumbhash == null || thumbhash.isEmpty()) return null;
110
+ Bitmap cached = cache.get(thumbhash);
111
+ if (cached != null) return cached;
112
+ StringBuilder base64 = new StringBuilder(thumbhash.trim().replace('-', '+').replace('_', '/'));
113
+ while (base64.length() % 4 != 0) base64.append('=');
114
+ byte[] hash;
115
+ try {
116
+ hash = Base64.decode(base64.toString(), Base64.NO_WRAP);
117
+ } catch (IllegalArgumentException e) {
118
+ return null;
119
+ }
120
+ int[] size = new int[2];
121
+ int[] colors = thumbHashToColors(hash, size);
122
+ if (colors == null) return null;
123
+ Bitmap bitmap = Bitmap.createBitmap(colors, size[0], size[1], Bitmap.Config.ARGB_8888);
124
+ cache.put(thumbhash, bitmap);
125
+ return bitmap;
126
+ }
127
+
128
+ // The hash decoded as unpremultiplied ARGB colors (Bitmap.createBitmap's),
129
+ // its width and height in size, or null if it's too short.
130
+ @Nullable
131
+ private static int[] thumbHashToColors(byte[] hash, int[] size) {
132
+ if (hash.length < 5) return null;
133
+ // Read the constants
134
+ int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
135
+ int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
136
+ float l_dc = (float) (header24 & 63) / 63.0f;
137
+ float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
138
+ float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
139
+ float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
140
+ boolean hasAlpha = (header24 >> 23) != 0;
141
+ float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
142
+ float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
143
+ boolean isLandscape = (header16 >> 15) != 0;
144
+ int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
145
+ int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
146
+ int ac_start = hasAlpha ? 6 : 5;
147
+ int ac_count = count(lx, ly) + 2 * count(3, 3) + (hasAlpha ? count(5, 5) : 0);
148
+ if (hash.length < ac_start + (ac_count + 1) / 2) return null;
149
+ float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
150
+ float a_scale = hasAlpha ? (float) ((hash[5] >> 4) & 15) / 15.0f : 0;
151
+
152
+ // Read the varying factors (boost saturation by 1.25x to compensate for quantization)
153
+ int[] ac_index = {0};
154
+ float[] l_ac = decode(hash, ac_start, ac_index, lx, ly, l_scale);
155
+ float[] p_ac = decode(hash, ac_start, ac_index, 3, 3, p_scale * 1.25f);
156
+ float[] q_ac = decode(hash, ac_start, ac_index, 3, 3, q_scale * 1.25f);
157
+ float[] a_ac = hasAlpha ? decode(hash, ac_start, ac_index, 5, 5, a_scale) : null;
158
+
159
+ // Decode using the DCT into RGB
160
+ float ratio = approximateAspectRatio(hash);
161
+ int w = Math.max(1, Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio));
162
+ int h = Math.max(1, Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f));
163
+ int[] colors = new int[w * h];
164
+ int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
165
+ int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
166
+ float[] fx = new float[cx_stop];
167
+ float[] fy = new float[cy_stop];
168
+ for (int y = 0, i = 0; y < h; y++) {
169
+ for (int x = 0; x < w; x++, i++) {
170
+ float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
171
+
172
+ // Precompute the coefficients
173
+ for (int cx = 0; cx < cx_stop; cx++)
174
+ fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
175
+ for (int cy = 0; cy < cy_stop; cy++)
176
+ fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
177
+
178
+ // Decode L
179
+ for (int cy = 0, j = 0; cy < ly; cy++) {
180
+ float fy2 = fy[cy] * 2.0f;
181
+ for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
182
+ l += l_ac[j] * fx[cx] * fy2;
183
+ }
184
+
185
+ // Decode P and Q
186
+ for (int cy = 0, j = 0; cy < 3; cy++) {
187
+ float fy2 = fy[cy] * 2.0f;
188
+ for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
189
+ float f = fx[cx] * fy2;
190
+ p += p_ac[j] * f;
191
+ q += q_ac[j] * f;
192
+ }
193
+ }
194
+
195
+ // Decode A
196
+ if (a_ac != null)
197
+ for (int cy = 0, j = 0; cy < 5; cy++) {
198
+ float fy2 = fy[cy] * 2.0f;
199
+ for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
200
+ a += a_ac[j] * fx[cx] * fy2;
201
+ }
202
+
203
+ // Convert to RGB
204
+ float b = l - 2.0f / 3.0f * p;
205
+ float r = (3.0f * l - b + q) / 2.0f;
206
+ float g = r - q;
207
+ colors[i] = (channel(a) << 24) | (channel(r) << 16) | (channel(g) << 8) | channel(b);
208
+ }
209
+ }
210
+ size[0] = w;
211
+ size[1] = h;
212
+ return colors;
213
+ }
214
+
215
+ private static int channel(float value) {
216
+ return Math.max(0, Math.round(255.0f * Math.min(1, value)));
217
+ }
218
+
219
+ // How many AC factors a channel of nx × ny has.
220
+ private static int count(int nx, int ny) {
221
+ int n = 0;
222
+ for (int cy = 0; cy < ny; cy++)
223
+ for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
224
+ n++;
225
+ return n;
226
+ }
227
+
228
+ private static float[] decode(byte[] hash, int start, int[] index, int nx, int ny, float scale) {
229
+ float[] ac = new float[count(nx, ny)];
230
+ for (int i = 0; i < ac.length; i++) {
231
+ int data = hash[start + (index[0] >> 1)] >> ((index[0] & 1) << 2);
232
+ ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
233
+ index[0]++;
234
+ }
235
+ return ac;
236
+ }
237
+
238
+ private static float approximateAspectRatio(byte[] hash) {
239
+ byte header = hash[3];
240
+ boolean hasAlpha = (hash[2] & 0x80) != 0;
241
+ boolean isLandscape = (hash[4] & 0x80) != 0;
242
+ int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
243
+ int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
244
+ return ly == 0 ? 1 : (float) lx / (float) ly;
245
+ }
246
+ }
@@ -145,7 +145,7 @@ class FastImageViewConverter {
145
145
  .set(FastImageGlide.REQUEST, true)
146
146
  .set(FastImageGlide.NO_STORE, noStore);
147
147
 
148
- // imageSource is null when only a defaultSource is shown.
148
+ // imageSource is null when only the placeholder prop is shown.
149
149
  if (imageSource != null && imageSource.isResource()) {
150
150
  // Every local resource (drawable) in Android has its own unique numeric id, which are
151
151
  // generated at build time. Although these ids are unique, they are not guaranteed unique
@@ -7,9 +7,12 @@ import static com.fastimage.FastImageRequestListener.REACT_ON_LOAD_EVENT;
7
7
  import android.app.Activity;
8
8
  import android.content.Context;
9
9
  import android.content.ContextWrapper;
10
+ import android.graphics.Bitmap;
10
11
  import android.graphics.PorterDuff;
11
12
  import android.util.Log;
12
13
  import android.graphics.Rect;
14
+ import android.graphics.drawable.BitmapDrawable;
15
+ import android.util.DisplayMetrics;
13
16
 
14
17
  import androidx.annotation.NonNull;
15
18
 
@@ -312,12 +315,19 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl>
312
315
  view.setSources(sources.isNull() ? null : sources.asArray());
313
316
  }
314
317
 
315
- // The resolved asset's uri.
318
+ // The `placeholder` prop: the resolved asset's uri, or a data uri.
316
319
  @Override
317
- public void setDefaultSource(FastImageViewWithUrl view, Dynamic source) {
318
- view.setDefaultSource(
319
- ResourceDrawableIdHelper.getResourceDrawable(
320
- view.getContext(), source.isNull() ? null : source.asString()));
320
+ public void setPlaceholder(FastImageViewWithUrl view, Dynamic source) {
321
+ String uri = source.isNull() ? null : source.asString();
322
+ if (uri != null && FastImageThumbHash.isDataUri(uri)) {
323
+ Bitmap bitmap = FastImageThumbHash.dataUri(uri);
324
+ // One pixel per dp, as on iOS and the web (decoded, it's at the
325
+ // screen's density: one pixel per pixel).
326
+ if (bitmap != null) bitmap.setDensity(DisplayMetrics.DENSITY_DEFAULT);
327
+ view.setPlaceholder(bitmap == null ? null : new BitmapDrawable(view.getResources(), bitmap));
328
+ } else {
329
+ view.setPlaceholder(ResourceDrawableIdHelper.getResourceDrawable(view.getContext(), uri));
330
+ }
321
331
  }
322
332
 
323
333
  @Override
@@ -334,6 +344,11 @@ class FastImageViewManager extends SimpleViewManager<FastImageViewWithUrl>
334
344
  view.setRecyclingKey(recyclingKey);
335
345
  }
336
346
 
347
+ @Override
348
+ public void setSourcePlaceholders(FastImageViewWithUrl view, @Nullable ReadableArray placeholders) {
349
+ view.setSourcePlaceholders(placeholders);
350
+ }
351
+
337
352
  @Override
338
353
  public void setLoopCount(FastImageViewWithUrl view, int loopCount) {
339
354
  view.setLoopCount(loopCount);