react-native-nitro-image-pipeline 1.5.0 → 1.7.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 (87) hide show
  1. package/README.md +142 -18
  2. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +72 -16
  3. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +47 -8
  4. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/FitGeometry.kt +104 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/PipelineRoundedCornersTransformation.kt +103 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt +69 -16
  7. package/ios/FitResizeProcessor.swift +127 -0
  8. package/ios/HybridNitroImagePipeline.swift +88 -24
  9. package/ios/PipelineImageLoader.swift +108 -18
  10. package/lib/commonjs/NativePipelineImage.js +20 -4
  11. package/lib/commonjs/NativePipelineImage.js.map +1 -1
  12. package/lib/commonjs/PipelineImage.js +13 -1
  13. package/lib/commonjs/PipelineImage.js.map +1 -1
  14. package/lib/commonjs/index.js +13 -0
  15. package/lib/commonjs/index.js.map +1 -1
  16. package/lib/commonjs/resizeForStyle.js +3 -1
  17. package/lib/commonjs/resizeForStyle.js.map +1 -1
  18. package/lib/commonjs/resolveImageSource.js +89 -0
  19. package/lib/commonjs/resolveImageSource.js.map +1 -0
  20. package/lib/commonjs/useImage.js +14 -6
  21. package/lib/commonjs/useImage.js.map +1 -1
  22. package/lib/commonjs/usePipelineImageLoader.js +114 -11
  23. package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
  24. package/lib/module/NativePipelineImage.js +20 -4
  25. package/lib/module/NativePipelineImage.js.map +1 -1
  26. package/lib/module/PipelineImage.js +13 -1
  27. package/lib/module/PipelineImage.js.map +1 -1
  28. package/lib/module/index.js +1 -0
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/resizeForStyle.js +3 -1
  31. package/lib/module/resizeForStyle.js.map +1 -1
  32. package/lib/module/resolveImageSource.js +83 -0
  33. package/lib/module/resolveImageSource.js.map +1 -0
  34. package/lib/module/useImage.js +14 -6
  35. package/lib/module/useImage.js.map +1 -1
  36. package/lib/module/usePipelineImageLoader.js +115 -12
  37. package/lib/module/usePipelineImageLoader.js.map +1 -1
  38. package/lib/typescript/src/NativePipelineImage.d.ts +51 -5
  39. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
  40. package/lib/typescript/src/PipelineImage.d.ts +35 -3
  41. package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
  42. package/lib/typescript/src/index.d.ts +2 -1
  43. package/lib/typescript/src/index.d.ts.map +1 -1
  44. package/lib/typescript/src/resizeForStyle.d.ts +3 -1
  45. package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
  46. package/lib/typescript/src/resolveImageSource.d.ts +56 -0
  47. package/lib/typescript/src/resolveImageSource.d.ts.map +1 -0
  48. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +95 -8
  49. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  50. package/lib/typescript/src/useImage.d.ts +13 -6
  51. package/lib/typescript/src/useImage.d.ts.map +1 -1
  52. package/lib/typescript/src/usePipelineImageLoader.d.ts +7 -3
  53. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
  54. package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
  55. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  56. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  57. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
  58. package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
  59. package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
  60. package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
  61. package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
  62. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
  66. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
  67. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
  68. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
  69. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
  70. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
  71. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  72. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  73. package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
  74. package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
  75. package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
  76. package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
  77. package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
  78. package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
  79. package/package.json +1 -1
  80. package/src/NativePipelineImage.tsx +75 -6
  81. package/src/PipelineImage.tsx +46 -3
  82. package/src/index.ts +6 -0
  83. package/src/resizeForStyle.ts +3 -1
  84. package/src/resolveImageSource.ts +83 -0
  85. package/src/specs/nitro-image-toolkit.nitro.ts +96 -8
  86. package/src/useImage.ts +38 -17
  87. package/src/usePipelineImageLoader.ts +126 -11
package/README.md CHANGED
@@ -8,9 +8,9 @@ A high-performance image loading, caching, and processing library for React Nati
8
8
 
9
9
  ## Features
10
10
 
11
- - Load remote images with built-in memory and disk caching
11
+ - Load images from the network (with built-in memory and disk caching), the file system, or bundled `require()` assets
12
12
  - Prefetch single or multiple images in the background
13
- - Resize (aspect-fill, center-crop) and apply Gaussian blur and rounded corners (uniform or per-corner) at load time
13
+ - Resize in every `resizeMode` (`cover`, `contain`, `stretch`, `center`) and apply Gaussian blur and rounded corners (uniform or per-corner) at load time
14
14
  - Apply Gaussian blur to already-loaded images
15
15
  - Clear the image cache on demand
16
16
  - `useImage` hook for declarative image loading in components
@@ -69,7 +69,9 @@ pixels internally, unlike the pixel-based values used everywhere else in this li
69
69
  so a style that already rounds the view rounds the bitmap too, with no separate prop. Pass
70
70
  `cornerRadius` explicitly to override that. `onLoad`/`onError` callbacks are supported, and every
71
71
  other prop (`resizeMode`, `recyclingKey`, `testID`, …) is passed straight through to
72
- `NativeNitroImage`.
72
+ `NativeNitroImage`. `resizeMode` also decides how the bitmap is produced: `resizeMode="contain"`
73
+ decodes an aspect-fitted bitmap (no crop, no padding) rather than a center-cropped one the view
74
+ then letterboxes — see [Resize modes](#resize-modes). Pass `fit` to decouple the two.
73
75
 
74
76
  ### `<NativePipelineImage>` component
75
77
 
@@ -93,11 +95,25 @@ import { NativePipelineImage } from 'react-native-nitro-image-pipeline';
93
95
  `blur`/`cornerRadius` are in points and `style`'s `borderRadius` is picked up automatically,
94
96
  exactly like `<PipelineImage>`. The trade-offs of going fully native:
95
97
 
96
- - No `onLoad`/`onError` the loaded `Image` never crosses into JS. Use `<PipelineImage>` or
97
- `useImage` when you need them.
98
+ - `onLoad` reports the bitmap's size in pixels rather than the `Image` itself, which never crosses
99
+ into JS — use `<PipelineImage>` or `useImage` when you need the `Image`. It means "this view is
100
+ now showing this image" rather than firing exactly once: a recycled cell re-attaching calls it
101
+ again, and a single view may call it more than once for the same image. Make it idempotent.
102
+ - Setting `onLoad`/`onError` means there is per-image JS work again. Leave them unset (the default)
103
+ and nothing crosses into JS at all.
98
104
  - The bitmap is loaded once at the size the view first has; if the view resizes later, the bitmap
99
105
  scales with it instead of reloading.
100
106
 
107
+ ```tsx
108
+ <NativePipelineImage
109
+ url={url}
110
+ style={styles.photo}
111
+ // Bitmap pixels, not points. Inline arrows are fine — they don't reload it.
112
+ onLoad={(width, height) => console.log(`loaded ${width}×${height}`)}
113
+ onError={(message) => setFailed(message)}
114
+ />;
115
+ ```
116
+
101
117
  Under the hood this is `NitroImagePipeline.createImageLoader(url, options)` — an
102
118
  [`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) driven by
103
119
  `NativeNitroImage` — so you can also use the `usePipelineImageLoader(url, options)` hook directly
@@ -177,6 +193,41 @@ function BlurFade({ url, blurred }: { url: string; blurred: boolean }) {
177
193
  }
178
194
  ```
179
195
 
196
+ ### Local images and `require()`
197
+
198
+ Every `url` in this library — `<PipelineImage>`, `<NativePipelineImage>`, `useImage`,
199
+ `usePipelineImageLoader` — also takes a `require()`d asset, and any `url` string may point at the
200
+ file system. The image goes through the same pipeline, so a bundled logo or a photo from the camera
201
+ roll gets the same resize-to-layout, blur and rounded corners as a download:
202
+
203
+ ```tsx
204
+ // A bundled asset — streamed from Metro in debug, read from the app bundle /
205
+ // resources in release, at the scale that matches the screen (like <Image>).
206
+ <PipelineImage url={require('./assets/logo.png')} style={styles.logo} blur={2} />
207
+
208
+ // A file on disk — a `file://` URL or a plain absolute path, e.g. the path
209
+ // react-native-nitro-image's `saveToTemporaryFileAsync` returns.
210
+ <NativePipelineImage url={`file://${photoPath}`} style={styles.thumb} />
211
+ <NativePipelineImage url={photoPath} style={styles.thumb} />
212
+ ```
213
+
214
+ The direct `NitroImagePipeline.loadImage`/`createImageLoader` calls take a string; resolve a
215
+ `require()` first with `resolveImageUrl`:
216
+
217
+ ```ts
218
+ import { NitroImagePipeline, resolveImageUrl } from 'react-native-nitro-image-pipeline';
219
+
220
+ const logo = await NitroImagePipeline.loadImage(resolveImageUrl(require('./assets/logo.png')), {
221
+ resize: { width: 200, height: 200 },
222
+ });
223
+ ```
224
+
225
+ Accepted `url` forms: `http(s)://`, `file://`, a plain absolute path, `data:`, and on Android also
226
+ `content://` URIs and bare drawable resource names (what `require()` resolves to in a release build
227
+ there). Local sources are cached **in memory only** — there is nothing to gain from copying a file
228
+ that is already on disk into the disk cache — so `cache: 'disk'` on a local `url` means no caching,
229
+ and `preLoadImage(s)` treats local sources as a no-op.
230
+
180
231
  ### `useImage` hook
181
232
 
182
233
  The simplest way to load an image in a component:
@@ -249,24 +300,67 @@ await NitroImagePipeline.clearCache();
249
300
 
250
301
  ### `loadImage(url, options?)`
251
302
 
252
- Loads an image from a URL and returns a `Promise<Image>`.
303
+ Loads an image from a URL and returns a `Promise<Image>`. `url` is a string — `http(s)://`, `file://`,
304
+ a plain absolute path, or the other forms listed under
305
+ [Local images and `require()`](#local-images-and-require); pass a `require()` through
306
+ [`resolveImageUrl`](#resolveimageurlsource) first.
253
307
 
254
308
  | Option | Type | Default | Description |
255
309
  |---|---|---|---|
256
310
  | `blur` | `number` | `0` | Gaussian blur strength applied at load time — see [Blur units](#blur-units) |
257
- | `resize` | `{ width, height }` | source size | Target bitmap size in pixels. Scales to fill and center-crops (CSS `object-fit: cover`, upscaling if needed) before `blur`/`cornerRadius` run, so their pixel units refer to this final size. Typically your display size in points × `PixelRatio.get()` |
311
+ | `resize` | `{ width, height, fit?, allowUpscale? }` | source size | Target bitmap box in pixels. By default scales to fill and center-crops (CSS `object-fit: cover`, upscaling if needed); `fit` selects the other modes and `allowUpscale: false` never enlarges the source — see [Resize modes](#resize-modes). Runs before `blur`/`cornerRadius`, so their pixel units refer to the produced bitmap. Typically your display size in points × `PixelRatio.get()` |
258
312
  | `cornerRadius` | `number \| CornerRadii` | `0` | Corner radius in pixels of the produced bitmap — a single number for all four corners, or `{ topLeft?, topRight?, bottomLeft?, bottomRight? }` for independent per-corner radii (omitted corners stay square). Pair with `resize` for radii that match your layout |
259
313
  | `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
260
314
 
315
+ #### Resize modes
316
+
317
+ `resize.fit` takes the same four values as `NativeNitroImage`'s `resizeMode`, so the bitmap can be
318
+ produced the way the view will display it. With a `w × h` source and a `W × H` box:
319
+
320
+ | `fit` | Scale | Produced bitmap | Notes |
321
+ |---|---|---|---|
322
+ | `'cover'` (default) | `max(W/w, H/h)` | exactly `W × H` | Center-cropped. Unchanged from earlier versions, byte for byte |
323
+ | `'contain'` | `min(W/w, H/h)` | `round(w·s) × round(h·s)` | Aspect-fit. The bitmap is **not** padded to the box |
324
+ | `'stretch'` | `W/w` and `H/h` independently | exactly `W × H` | Aspect ratio ignored |
325
+ | `'center'` | `1` | `min(w, W) × min(h, H)` | Never resamples; only center-crops what the box could not show |
326
+
327
+ `allowUpscale: false` clamps the scale to at most 1 for `cover`, `contain` and `stretch`, so a
328
+ source smaller than the box keeps its own size (under `cover` it is only cropped to the box). It
329
+ has no effect on `center`, which never scales. Some examples:
330
+
331
+ | Source → box | `cover` | `contain` | `stretch` | `center` |
332
+ |---|---|---|---|---|
333
+ | 2000×1000 → 400×400 | 400×400 | 400×200 | 400×400 | 400×400 (cropped) |
334
+ | 100×50 → 400×400 | 400×400 | 400×200 | 400×400 | 100×50 |
335
+ | 100×50 → 400×400, `allowUpscale: false` | 100×50 | 100×50 | 100×50 | 100×50 |
336
+
337
+ `blur` and `cornerRadius` apply to the produced bitmap, so under `contain` the corners round the
338
+ image's own edges, not the box's. Displaying a `center` bitmap pixel for pixel needs the view's
339
+ `resizeMode="center"`, which `<NativePipelineImage>` (and `createImageLoader`) draw 1:1 on both
340
+ platforms; an `Image` passed to `<NativeNitroImage>` or `<PipelineImage>` is a scale-1 image that
341
+ iOS's `center` mode draws at 1 pt per pixel (nitro-image's `Image` contract — `PixelRatio.get()`×
342
+ too large on a Retina screen). Both platforms implement the same geometry (down to the
343
+ rounding), and the harness suites check every cell of this table on each. How much is decoded before
344
+ the resize depends on the mode. `contain` decodes aspect-fit, so the decode is always near the
345
+ produced size. `cover` and `stretch` decode aspect-*fill* — the source scaled until it covers the
346
+ box — which is near the produced size when the aspect ratios are similar, but a source whose
347
+ aspect ratio differs sharply from the box (a panorama into a square) is decoded larger, up to its
348
+ full size, before the crop or stretch; `cover` has always worked this way, and `stretch` needs the
349
+ fill scale to keep the axis that is shrunk less from being resampled up from a few pixels.
350
+ `center` decodes the source at full resolution — it has to see the source's own pixels. Prefer
351
+ `contain`, or a box with a similar aspect ratio, for very large images.
352
+
261
353
  ### `<PipelineImage>`
262
354
 
263
355
  | Prop | Type | Default | Description |
264
356
  |---|---|---|---|
265
- | `url` | `string` | — | Image URL to load |
357
+ | `url` | `string \| number` | — | Image to load: a URL string (`https://`, `file://`, an absolute path) or a `require()`d asset |
266
358
  | `style` | `StyleProp<ViewStyle>` | — | Layout style; also determines the resize target (see [`resizeForStyle`](#resizeforstyle-style--resizeforlayoutwidth-height)) and, if `cornerRadius` is omitted, the corner radius (see [`cornerRadiusForStyle`](#cornerradiusforstylestyle)) |
267
359
  | `blur` | `number` | `0` | Gaussian blur strength, in **points** (converted to bitmap pixels internally) |
268
360
  | `cornerRadius` | `number \| CornerRadii` | derived from `style` | Corner radius, in **points** (converted to bitmap pixels internally). When omitted, derived from `style`'s `borderRadius`/`borderTopLeftRadius`/etc.; square if neither is set |
269
361
  | `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
362
+ | `fit` | `ResizeFit` | derived from `resizeMode` | How the bitmap is fitted into the display size — see [Resize modes](#resize-modes). When omitted it follows `resizeMode` (`'cover'` if that is unset too); set it to decouple the two. On iOS, `resizeMode="center"` draws the (scale-1) `Image` at 1 pt per pixel — a nitro-image `Image` limitation; use `<NativePipelineImage>` for pixel-exact `center` on both platforms |
363
+ | `allowUpscale` | `boolean` | `true` | `false` never enlarges a source smaller than the display size when the *bitmap* is produced (nothing is decoded or blurred larger than the source). The view still scales it per `resizeMode`; use `resizeMode="center"` to show it at its natural size |
270
364
  | `onLoad` | `(image: Image) => void` | — | Called when the image finishes loading |
271
365
  | `onError` | `(error: Error) => void` | — | Called if loading fails |
272
366
  | `onLayout` | `(event: LayoutChangeEvent) => void` | — | Standard `View` layout callback; also drives the deferred resize for non-numeric sizes |
@@ -277,33 +371,58 @@ Loads an image from a URL and returns a `Promise<Image>`.
277
371
 
278
372
  | Prop | Type | Default | Description |
279
373
  |---|---|---|---|
280
- | `url` | `string` | — | Image URL to load |
374
+ | `url` | `string \| number` | — | Image to load: a URL string (`https://`, `file://`, an absolute path) or a `require()`d asset |
281
375
  | `style` | `StyleProp<ViewStyle>` | — | Layout style. The native side measures the view and loads at that size; `borderRadius`-family properties drive `cornerRadius` when it's omitted |
282
376
  | `blur` | `number` | `0` | Gaussian blur strength, in **points** (screen scale applied natively) |
283
377
  | `cornerRadius` | `number \| CornerRadii` | derived from `style` | Corner radius, in **points** (screen scale applied natively) |
284
378
  | `cache` | `'memory' \| 'disk' \| 'none'` | platform default | Caching strategy |
285
- | `resize` | `{ width, height }` | measured from the view | Explicit target bitmap size in **pixels**, skipping the native measurement. Rarely needed |
379
+ | `resize` | `{ width, height, fit?, allowUpscale? }` | measured from the view | Explicit target bitmap box in **pixels**, skipping the native measurement. Rarely needed |
380
+ | `fit` | `ResizeFit` | derived from `resizeMode` | How the bitmap is fitted into the measured size — see [Resize modes](#resize-modes). Follows `resizeMode` when omitted; set it to decouple the two |
381
+ | `allowUpscale` | `boolean` | `true` | `false` never enlarges a source smaller than the view when the *bitmap* is produced. The view still scales it per `resizeMode`; `resizeMode="center"` shows it pixel for pixel on both platforms |
382
+ | `onLoad` | `(width: number, height: number) => void` | — | Called when the view has displayed the image, with the bitmap's size in **pixels**. Hands you the size rather than the `Image`, which stays native. Not a one-shot event — a recycled cell calls it again on re-attach, and one view may call it more than once for the same image, so make it idempotent |
383
+ | `onError` | `(message: string) => void` | — | Called when loading fails, with the error's message. A load cancelled by the view detaching is not a failure. Without it, failures on this component are silent |
286
384
  | `ref` | `Ref<NativePipelineImageRef>` | — | Forwarded to the underlying `NativeNitroImage` host view |
287
385
  | `…NativeNitroImage props` | — | — | Everything else (`resizeMode`, `recyclingKey`, `testID`, …) is passed through; `recyclingKey` defaults to `url` |
288
386
 
289
- No `onLoad`/`onError`: loading happens entirely natively and the result never crosses into JS.
387
+ `onLoad`/`onError` are the only things that cross into JS here, and only when you pass them: unset,
388
+ loading happens entirely natively. The `Image` itself never crosses — use `<PipelineImage>` or
389
+ `useImage` for that.
290
390
 
291
- ### `createImageLoader(url, options?)` / `usePipelineImageLoader(url, options?)`
391
+ ### `createImageLoader(url, options?)` / `usePipelineImageLoader(source, options?)`
292
392
 
293
393
  Creates the [`ImageLoader`](https://github.com/mrousavy/react-native-nitro-image) that powers
294
394
  `<NativePipelineImage>`, for use with your own `<NativeNitroImage image={loader} />`. The view
295
395
  calls into it natively when it attaches (load at the view's laid-out size) and detaches
296
- (cancel + release). `options` takes `blur`/`cornerRadius` in **points** and an optional
297
- pixel-based `resize` override see the `ViewOptions` type. The hook memoizes by value, so
298
- inline options literals are fine. `loader.loadImage()` also works imperatively and resolves with
299
- the processed `Image`.
396
+ (cancel + release). `options` takes `blur`/`cornerRadius` in **points**, an optional
397
+ pixel-based `resize` override, and `fit`/`allowUpscale` for the box the view measures (pass the
398
+ view's `resizeMode` as `fit`; a `resize` that carries its own wins) — see the `ViewOptions` type.
399
+ Unlike the callbacks, `fit` is part of what is loaded, so changing it swaps the loader. It also
400
+ takes the optional
401
+ `onLoad(width, height)`/`onError(message)` callbacks a view reports its loads through; without
402
+ them the loader never calls into JS. The hook memoizes by value, so inline options literals are
403
+ fine — the callbacks are read through a ref, so inline arrows don't reload the image either — and
404
+ its `source` may be a `require()` as well as a URL string. `loader.loadImage()` also works
405
+ imperatively and resolves with the processed `Image`; it reports through its promise, not through
406
+ `onLoad`/`onError`.
407
+
408
+ ### `resolveImageUrl(source)`
409
+
410
+ Turns an `ImageSource` (`string | number`) into the URL string the native pipeline loads: strings
411
+ pass through unchanged, a `require()`d asset is resolved with `Image.resolveAssetSource` to the
412
+ scale-matched variant. The components and hooks do this internally; use it when calling
413
+ `loadImage`, `createImageLoader` or `preLoadImage(s)` directly with a `require()`. Throws if the
414
+ number is not a registered asset; the components and hooks never throw for one — `useImage` reports
415
+ it through `error`, and `usePipelineImageLoader`/`<NativePipelineImage>` load a URL no loader can
416
+ resolve, so the request fails at load time like a missing file.
300
417
 
301
418
  ### `resizeForStyle(style)` / `resizeForLayout(width, height)`
302
419
 
303
420
  Converts a layout size in points to a bitmap `resize` option in pixels. Returns
304
421
  `{ width, height }` in whole pixels via `PixelRatio.getPixelSizeForLayoutSize`, or `undefined` for
305
422
  non-numeric sizes (e.g. `'100%'`, `undefined`) — `resizeForStyle` reads `style.width`/`style.height`,
306
- `resizeForLayout` takes explicit numbers.
423
+ `resizeForLayout` takes explicit numbers. Spread a `fit` into the result for the other
424
+ [resize modes](#resize-modes); under `contain` and `center` the size is then the box the bitmap
425
+ fits in, not necessarily its final size.
307
426
 
308
427
  ### `cornerRadiusForStyle(style)`
309
428
 
@@ -317,6 +436,7 @@ omitted.
317
436
  ### `preLoadImage(url)`
318
437
 
319
438
  Prefetches a single image into the **disk cache**, without decoding it. Returns `Promise<void>`.
439
+ Local sources (`file://`, paths, resources) have no download to cache and are a no-op.
320
440
 
321
441
  Prefetching only pays the network and disk I/O cost up front — no bitmap is decoded or held in
322
442
  memory, so prefetching a long list of URLs doesn't balloon RAM. The image is decoded (at the
@@ -389,7 +509,11 @@ The pipeline is set up so RAM scales with what you display, not with what you do
389
509
  - **Pass `resize` (or just use `<PipelineImage>`, which derives it from layout).** With a target
390
510
  size known, both platforms decode the source *near that size* instead of at full resolution —
391
511
  iOS via a downsampled thumbnail decode, Android via Coil's subsampling. Without `resize`, a
392
- 48 MP photo decompresses to ~190 MB of bitmap no matter how small you display it.
512
+ 48 MP photo decompresses to ~190 MB of bitmap no matter how small you display it. "Near that
513
+ size" means the aspect-*fill* decode for `cover` and `stretch` — the source scaled until it
514
+ covers the box, so a panorama into a square box still decodes near its full width — and the
515
+ aspect-fit decode for `contain`, which is always near the produced size. `fit: 'center'` by
516
+ definition needs the source's own pixels and decodes at full resolution before cropping.
393
517
  - **Android draws transformed images from hardware bitmaps** (API 26+). When a view loads its
394
518
  image natively (`<NativePipelineImage>`, or `<NativeNitroImage image={createImageLoader(...)}>`),
395
519
  a resized, blurred or rounded result is uploaded to a `Bitmap.Config.HARDWARE` bitmap once and
@@ -3,6 +3,7 @@ package com.margelo.nitro.nitroimagepipeline
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import androidx.core.graphics.drawable.toBitmap
6
+ import androidx.core.net.toUri
6
7
  import coil3.BitmapImage
7
8
  import coil3.ColorImage
8
9
  import coil3.DrawableImage
@@ -18,6 +19,7 @@ import coil3.request.ImageRequest
18
19
  import coil3.request.SuccessResult
19
20
  import coil3.request.allowHardware
20
21
  import coil3.request.transformations
22
+ import coil3.size.Precision
21
23
  import coil3.size.Scale
22
24
  import coil3.size.Size
23
25
  import coil3.transform.RoundedCornersTransformation
@@ -28,6 +30,7 @@ import com.margelo.nitro.image.HybridImage
28
30
  import com.margelo.nitro.image.HybridImageSpec
29
31
  import com.margelo.nitro.nitroimagepipeline.transform.BlurTransformation
30
32
  import com.margelo.nitro.nitroimagepipeline.transform.HardwareBitmapTransformation
33
+ import com.margelo.nitro.nitroimagepipeline.transform.PipelineRoundedCornersTransformation
31
34
  import com.margelo.nitro.nitroimagepipeline.transform.ResizeTransformation
32
35
  import kotlin.math.roundToInt
33
36
  import kotlinx.coroutines.CoroutineScope
@@ -76,7 +79,7 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
76
79
  // size — only when a loadImage actually displays it.
77
80
  private fun preloadRequest(url: String): ImageRequest =
78
81
  ImageRequest.Builder(context)
79
- .data(url)
82
+ .data(requestData(context, url))
80
83
  .memoryCachePolicy(CachePolicy.DISABLED)
81
84
  .decoderFactory { _, _, _ -> Decoder { DecodeResult(ColorImage(), false) } }
82
85
  .build()
@@ -151,21 +154,27 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
151
154
  val height = r.height.roundToInt()
152
155
  if (width > 0 && height > 0) width to height else null
153
156
  }
154
- val roundedCorners: RoundedCornersTransformation? =
157
+ val fit = options?.resize?.fit ?: ResizeFit.COVER
158
+ val allowUpscale = options?.resize?.allowUpscale ?: true
159
+ // The resize as it behaved before `fit` existed — aspect-fill, crop,
160
+ // upscale — which keeps the original transformations and cache keys.
161
+ val isDefaultFit = fit == ResizeFit.COVER && allowUpscale
162
+ // Radii in bitmap pixels as (topLeft, topRight, bottomLeft, bottomRight),
163
+ // or null when no corner is actually rounded. Negative radii are square.
164
+ val cornerRadii: FloatArray? =
155
165
  options
156
166
  ?.cornerRadius
157
167
  ?.match(
158
168
  first = { radius ->
159
- if (radius > 0.0) RoundedCornersTransformation(radius.toFloat()) else null
169
+ if (radius > 0.0) FloatArray(4) { radius.toFloat() } else null
160
170
  },
161
171
  second = { radii ->
162
- // RoundedCornersTransformation rejects negative radii; treat them as square.
163
172
  val topLeft = (radii.topLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
164
173
  val topRight = (radii.topRight?.toFloat() ?: 0f).coerceAtLeast(0f)
165
174
  val bottomLeft = (radii.bottomLeft?.toFloat() ?: 0f).coerceAtLeast(0f)
166
175
  val bottomRight = (radii.bottomRight?.toFloat() ?: 0f).coerceAtLeast(0f)
167
176
  if (topLeft > 0f || topRight > 0f || bottomLeft > 0f || bottomRight > 0f) {
168
- RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight)
177
+ floatArrayOf(topLeft, topRight, bottomLeft, bottomRight)
169
178
  } else {
170
179
  null
171
180
  }
@@ -176,14 +185,25 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
176
185
  // they run on, so they must see the final size. Coil's
177
186
  // RoundedCornersTransformation already scale-fills and center-crops
178
187
  // to the request size (`resize`, when set) as part of its own draw,
179
- // so with rounded corners and no blur the explicit resize would only
180
- // add an intermediate bitmap — and the radii still apply to the final
181
- // size, because that is the size its output has.
182
- if (resize != null && (blur > 0f || roundedCorners == null)) {
183
- add(ResizeTransformation(resize.first, resize.second))
188
+ // so with the default fit, rounded corners and no blur the explicit
189
+ // resize would only add an intermediate bitmap — and the radii still
190
+ // apply to the final size, because that is the size its output has.
191
+ // Any other fit produces a bitmap that may be smaller than the box,
192
+ // so it always resizes explicitly and rounds at the produced size.
193
+ if (resize != null && (blur > 0f || cornerRadii == null || !isDefaultFit)) {
194
+ add(ResizeTransformation(resize.first, resize.second, fit, allowUpscale))
184
195
  }
185
196
  if (blur > 0f) add(BlurTransformation(blur))
186
- roundedCorners?.let { add(it) }
197
+ cornerRadii?.let { (topLeft, topRight, bottomLeft, bottomRight) ->
198
+ if (resize == null || isDefaultFit) {
199
+ // The uniform constructor keeps the original cache key for a single radius.
200
+ val uniform = topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight
201
+ if (uniform) add(RoundedCornersTransformation(topLeft))
202
+ else add(RoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight))
203
+ } else {
204
+ add(PipelineRoundedCornersTransformation(topLeft, topRight, bottomLeft, bottomRight))
205
+ }
206
+ }
187
207
  // Without transformations Coil decodes straight to a hardware bitmap
188
208
  // already (allowHardware below); only a transformed result needs the
189
209
  // explicit upload.
@@ -192,7 +212,7 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
192
212
  }
193
213
  }
194
214
  return ImageRequest.Builder(context)
195
- .data(url)
215
+ .data(requestData(context, url))
196
216
  .apply {
197
217
  when (options?.cache) {
198
218
  CacheOption.MEMORY -> {
@@ -211,10 +231,22 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
211
231
  }
212
232
  // Ask the decoder for the target size so a large source is
213
233
  // subsampled near it instead of decoded at full resolution;
214
- // ResizeTransformation then makes the size exact.
215
- resize?.let { (width, height) ->
216
- size(width, height)
217
- scale(Scale.FILL)
234
+ // ResizeTransformation then makes the size exact. `contain`
235
+ // decodes to *fit* the box: its output follows the smaller
236
+ // scale, and a fill decode of a wide panorama into a small box
237
+ // would be near the source's full width before the
238
+ // transformation shrinks it (iOS's aspect-fit thumbnail bounds
239
+ // it the same way). `center` never scales, so it must see the
240
+ // source's own pixels — the default Size.ORIGINAL — or it would
241
+ // crop the wrong ones.
242
+ if (resize != null && fit != ResizeFit.CENTER) {
243
+ size(resize.first, resize.second)
244
+ scale(if (fit == ResizeFit.CONTAIN) Scale.FIT else Scale.FILL)
245
+ // With an explicit size Coil also *upscales* the decode to it,
246
+ // which the default fit doesn't mind (the transformation ends
247
+ // at the box anyway) but `allowUpscale: false` and `contain`
248
+ // must not see. Only subsample; the transformation scales.
249
+ if (!isDefaultFit) precision(Precision.INEXACT)
218
250
  }
219
251
  }
220
252
  .allowHardware(true)
@@ -222,6 +254,30 @@ class HybridNitroImagePipeline : HybridNitroImagePipelineSpec() {
222
254
  .build()
223
255
  }
224
256
 
257
+ /**
258
+ * What Coil should load for [url]. Coil itself handles `http(s)://`, `file://`, `content://`
259
+ * and plain absolute paths. The one form it doesn't is a bare resource name — a string with no
260
+ * scheme, like `src_assets_logo` — which is what React Native's `require()` resolves to in a
261
+ * release build (assets are packed into `res/drawable-*`). Resolve that to the resource id
262
+ * here; the density-qualified variant is then picked by the resources system, like `<Image>`
263
+ * does. An unknown name is passed through so Coil reports the failure as an [ErrorResult] (the
264
+ * loaders must not throw while building a request).
265
+ */
266
+ internal fun requestData(context: Context, url: String): Any {
267
+ if (url.startsWith("/") || url.toUri().scheme != null) return url
268
+ // Tolerate `logo.png`: RN's generated names carry no extension, but nitro-image's
269
+ // `{ resource: 'logo.png' }` convention does.
270
+ val name = url.substringBefore('.')
271
+ for (type in RESOURCE_TYPES) {
272
+ val id = context.resources.getIdentifier(name, type, context.packageName)
273
+ if (id != 0) return id
274
+ }
275
+ return url
276
+ }
277
+
278
+ /** Where React Native puts bundled image assets, then the other image resource types. */
279
+ private val RESOURCE_TYPES = arrayOf("drawable", "mipmap", "raw")
280
+
225
281
  /** The decoded bitmap of a successful load. */
226
282
  internal fun bitmapOf(result: SuccessResult): Bitmap =
227
283
  when (val img = result.image) {
@@ -15,6 +15,7 @@ import com.margelo.nitro.image.HybridImageSpec
15
15
  import com.margelo.nitro.image.HybridImageView
16
16
  import com.margelo.nitro.image.HybridNitroImageViewSpec
17
17
  import kotlin.coroutines.resume
18
+ import kotlinx.coroutines.CancellationException
18
19
  import kotlinx.coroutines.CoroutineScope
19
20
  import kotlinx.coroutines.Dispatchers
20
21
  import kotlinx.coroutines.Job
@@ -36,6 +37,11 @@ import kotlinx.coroutines.suspendCancellableCoroutine
36
37
  * the shared request builder using the display density, so cache keys match an equivalent
37
38
  * `loadImage` call. View sizes are already physical pixels on Android, so only blur and corner
38
39
  * radii need scaling.
40
+ *
41
+ * The optional `onLoad`/`onError` callbacks report a view's load back to JS. They are per request,
42
+ * not per loader: several views (and a recycled cell re-attaching) each call them, and a view that
43
+ * requests twice reports twice — `HybridImageView` requests both when its `image` is set and when
44
+ * it becomes visible. [loadImage] does not call them — it reports through its promise instead.
39
45
  */
40
46
  @DoNotStrip
41
47
  @Keep
@@ -56,7 +62,8 @@ class PipelineImageLoader(
56
62
 
57
63
  /**
58
64
  * The point-based [ViewOptions] as pixel-based [Options], resolved against the display [scale]
59
- * and the target size in pixels.
65
+ * and the target size in pixels. The fit comes from the explicit `resize` when it carries one,
66
+ * else from the top-level `fit`/`allowUpscale`, which apply to the measured size.
60
67
  */
61
68
  private fun pixelOptions(scale: Float, resize: ResizeOptions?): Options {
62
69
  val cornerRadius =
@@ -79,7 +86,15 @@ class PipelineImageLoader(
79
86
  blur = options?.blur?.times(scale),
80
87
  cache = options?.cache,
81
88
  cornerRadius = cornerRadius,
82
- resize = resize,
89
+ resize =
90
+ resize?.let {
91
+ ResizeOptions(
92
+ width = it.width,
93
+ height = it.height,
94
+ fit = it.fit ?: options?.fit,
95
+ allowUpscale = it.allowUpscale ?: options?.allowUpscale,
96
+ )
97
+ },
83
98
  )
84
99
  }
85
100
 
@@ -123,10 +138,18 @@ class PipelineImageLoader(
123
138
  // touch the view.
124
139
  if (!isActive) return@launch
125
140
  when (result) {
126
- is SuccessResult -> imageView.setImageBitmap(HybridNitroImagePipeline.bitmapOf(result))
127
- // Deliberately no URL in the message — signed URLs and query
128
- // tokens must not leak into consuming apps' Logcat.
129
- is ErrorResult -> Log.w(TAG, "Failed to load image", result.throwable)
141
+ is SuccessResult -> {
142
+ val bitmap = HybridNitroImagePipeline.bitmapOf(result)
143
+ imageView.setImageBitmap(bitmap)
144
+ report { options?.onLoad?.invoke(bitmap.width.toDouble(), bitmap.height.toDouble()) }
145
+ }
146
+ is ErrorResult -> {
147
+ // Deliberately no URL in the message — signed URLs and query
148
+ // tokens must not leak into consuming apps' Logcat.
149
+ val throwable = result.throwable
150
+ Log.w(TAG, "Failed to load image", throwable)
151
+ report { options?.onError?.invoke(throwable.message ?: throwable.toString()) }
152
+ }
130
153
  }
131
154
  }
132
155
  jobs[forView] = job
@@ -139,10 +162,26 @@ class PipelineImageLoader(
139
162
  view.imageView.setImageDrawable(null)
140
163
  }
141
164
 
165
+ /**
166
+ * Reports to JS. Calling a JS callback throws if the callback itself throws, or if the runtime is
167
+ * already gone — a load can land after it was torn down or reloaded — and an exception escaping
168
+ * this coroutine would reach the thread's uncaught handler and take the app with it. A report
169
+ * nobody is left to receive is not worth a crash.
170
+ */
171
+ private fun report(block: () -> Unit) {
172
+ try {
173
+ block()
174
+ } catch (cancellation: CancellationException) {
175
+ throw cancellation
176
+ } catch (throwable: Throwable) {
177
+ Log.w(TAG, "Image callback failed", throwable)
178
+ }
179
+ }
180
+
142
181
  /** The view's laid-out size in pixels, suspending until it has one. */
143
182
  private suspend fun measuredSize(view: ImageView): ResizeOptions {
144
183
  if (view.width > 0 && view.height > 0) {
145
- return ResizeOptions(view.width.toDouble(), view.height.toDouble())
184
+ return ResizeOptions(view.width.toDouble(), view.height.toDouble(), null, null)
146
185
  }
147
186
  return suspendCancellableCoroutine { continuation ->
148
187
  val listener =
@@ -162,7 +201,7 @@ class PipelineImageLoader(
162
201
  val height = bottom - top
163
202
  if (width > 0 && height > 0) {
164
203
  v.removeOnLayoutChangeListener(this)
165
- continuation.resume(ResizeOptions(width.toDouble(), height.toDouble()))
204
+ continuation.resume(ResizeOptions(width.toDouble(), height.toDouble(), null, null))
166
205
  }
167
206
  }
168
207
  }
@@ -0,0 +1,104 @@
1
+ package com.margelo.nitro.nitroimagepipeline.transform
2
+
3
+ import com.margelo.nitro.nitroimagepipeline.ResizeFit
4
+ import kotlin.math.max
5
+ import kotlin.math.min
6
+ import kotlin.math.roundToInt
7
+
8
+ /**
9
+ * The resize geometry both platforms implement — a port of `FitGeometry` in
10
+ * `ios/FitResizeProcessor.swift`; `example/__tests__/fit-geometry.ts` is the copy the harness
11
+ * checks both against. With a `w` × `h` source and a `W` × `H` box:
12
+ * ```
13
+ * cover : sx = sy = max(W/w, H/h)
14
+ * contain : sx = sy = min(W/w, H/h)
15
+ * stretch : sx = W/w ; sy = H/h
16
+ * center : sx = sy = 1
17
+ * if !allowUpscale: sx = min(sx, 1) ; sy = min(sy, 1)
18
+ * scaled = (round(w·sx), round(h·sy))
19
+ * output = (min(W, scaled.w), min(H, scaled.h))
20
+ * origin = trunc((output − scaled) / 2) — a centred, whole-pixel crop
21
+ * ```
22
+ *
23
+ * The source is drawn scaled to `scaled` at `origin` on an `output`-sized canvas. Rounding is
24
+ * half-away-from-zero (`roundToInt()` here, `.rounded()` in Swift — identical for the positive
25
+ * values involved), and the divisions are done in the same order in `Double` on both sides so the
26
+ * two agree to the pixel.
27
+ */
28
+ internal data class FitGeometry(
29
+ val sourceWidth: Int,
30
+ val sourceHeight: Int,
31
+ val scaledWidth: Int,
32
+ val scaledHeight: Int,
33
+ val outputWidth: Int,
34
+ val outputHeight: Int,
35
+ val originX: Int,
36
+ val originY: Int,
37
+ ) {
38
+ /**
39
+ * Whether the source is already the output — nothing to draw. Both sizes are compared with the
40
+ * *source*: `scaled == output` alone also holds for every plain downscale (nothing is cropped),
41
+ * which is not an identity.
42
+ */
43
+ val isIdentity: Boolean
44
+ get() =
45
+ scaledWidth == sourceWidth &&
46
+ scaledHeight == sourceHeight &&
47
+ outputWidth == sourceWidth &&
48
+ outputHeight == sourceHeight
49
+
50
+ companion object {
51
+ fun of(
52
+ sourceWidth: Int,
53
+ sourceHeight: Int,
54
+ boxWidth: Int,
55
+ boxHeight: Int,
56
+ fit: ResizeFit,
57
+ allowUpscale: Boolean,
58
+ ): FitGeometry {
59
+ val source = sourceWidth.toDouble() to sourceHeight.toDouble()
60
+ val box = boxWidth.toDouble() to boxHeight.toDouble()
61
+ var scaleX: Double
62
+ var scaleY: Double
63
+ when (fit) {
64
+ ResizeFit.COVER -> {
65
+ val scale = max(box.first / source.first, box.second / source.second)
66
+ scaleX = scale
67
+ scaleY = scale
68
+ }
69
+ ResizeFit.CONTAIN -> {
70
+ val scale = min(box.first / source.first, box.second / source.second)
71
+ scaleX = scale
72
+ scaleY = scale
73
+ }
74
+ ResizeFit.STRETCH -> {
75
+ scaleX = box.first / source.first
76
+ scaleY = box.second / source.second
77
+ }
78
+ ResizeFit.CENTER -> {
79
+ scaleX = 1.0
80
+ scaleY = 1.0
81
+ }
82
+ }
83
+ if (!allowUpscale) {
84
+ scaleX = min(scaleX, 1.0)
85
+ scaleY = min(scaleY, 1.0)
86
+ }
87
+ val scaledWidth = (source.first * scaleX).roundToInt().coerceAtLeast(1)
88
+ val scaledHeight = (source.second * scaleY).roundToInt().coerceAtLeast(1)
89
+ val outputWidth = min(boxWidth, scaledWidth)
90
+ val outputHeight = min(boxHeight, scaledHeight)
91
+ return FitGeometry(
92
+ sourceWidth = sourceWidth,
93
+ sourceHeight = sourceHeight,
94
+ scaledWidth = scaledWidth,
95
+ scaledHeight = scaledHeight,
96
+ outputWidth = outputWidth,
97
+ outputHeight = outputHeight,
98
+ // Integer division truncates toward zero, like `.rounded(.towardZero)` on iOS.
99
+ originX = (outputWidth - scaledWidth) / 2,
100
+ originY = (outputHeight - scaledHeight) / 2,
101
+ )
102
+ }
103
+ }
104
+ }