react-native-nitro-image-pipeline 1.6.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 (78) hide show
  1. package/README.md +88 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +51 -20
  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 +41 -15
  9. package/ios/PipelineImageLoader.swift +107 -17
  10. package/lib/commonjs/NativePipelineImage.js +18 -3
  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.map +1 -1
  15. package/lib/commonjs/resizeForStyle.js +3 -1
  16. package/lib/commonjs/resizeForStyle.js.map +1 -1
  17. package/lib/commonjs/useImage.js +6 -2
  18. package/lib/commonjs/useImage.js.map +1 -1
  19. package/lib/commonjs/usePipelineImageLoader.js +107 -9
  20. package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
  21. package/lib/module/NativePipelineImage.js +18 -3
  22. package/lib/module/NativePipelineImage.js.map +1 -1
  23. package/lib/module/PipelineImage.js +13 -1
  24. package/lib/module/PipelineImage.js.map +1 -1
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/resizeForStyle.js +3 -1
  27. package/lib/module/resizeForStyle.js.map +1 -1
  28. package/lib/module/useImage.js +6 -2
  29. package/lib/module/useImage.js.map +1 -1
  30. package/lib/module/usePipelineImageLoader.js +108 -10
  31. package/lib/module/usePipelineImageLoader.js.map +1 -1
  32. package/lib/typescript/src/NativePipelineImage.d.ts +44 -3
  33. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
  34. package/lib/typescript/src/PipelineImage.d.ts +28 -1
  35. package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
  36. package/lib/typescript/src/index.d.ts +1 -1
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/resizeForStyle.d.ts +3 -1
  39. package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
  40. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +83 -8
  41. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  42. package/lib/typescript/src/useImage.d.ts +3 -2
  43. package/lib/typescript/src/useImage.d.ts.map +1 -1
  44. package/lib/typescript/src/usePipelineImageLoader.d.ts +3 -1
  45. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
  46. package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
  47. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  48. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  49. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
  50. package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
  51. package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
  52. package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
  53. package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
  59. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
  60. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
  61. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
  62. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
  63. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  65. package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
  66. package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
  67. package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
  68. package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
  69. package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
  70. package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
  71. package/package.json +1 -1
  72. package/src/NativePipelineImage.tsx +67 -3
  73. package/src/PipelineImage.tsx +39 -1
  74. package/src/index.ts +1 -0
  75. package/src/resizeForStyle.ts +3 -1
  76. package/src/specs/nitro-image-toolkit.nitro.ts +84 -8
  77. package/src/useImage.ts +13 -3
  78. package/src/usePipelineImageLoader.ts +115 -8
@@ -6,53 +6,106 @@ import android.graphics.Matrix
6
6
  import android.graphics.Paint
7
7
  import coil3.size.Size
8
8
  import coil3.transform.Transformation
9
+ import com.margelo.nitro.nitroimagepipeline.ResizeFit
9
10
  import kotlin.math.max
10
11
 
11
12
  /**
12
- * Scales the input to fill exactly [width] × [height] pixels and center-crops the overflow (CSS
13
- * `object-fit: cover`), upscaling smaller sources. It runs before [BlurTransformation] and rounded
14
- * corners so their pixel units refer to the final bitmap matching Nuke's
15
- * `ImageProcessors.Resize(contentMode: .aspectFill, crop: true)` on iOS.
13
+ * Resizes the input into a [width] × [height] pixel box under [fit] (see [FitGeometry] for the size
14
+ * each mode produces). The default — [ResizeFit.COVER] with [allowUpscale] scales the input to
15
+ * fill the box exactly and center-crops the overflow (CSS `object-fit: cover`), upscaling smaller
16
+ * sources, on the very same code path (and cache key) it had before `fit` existed, matching Nuke's
17
+ * `ImageProcessors.Resize(contentMode: .aspectFill, crop: true)` on iOS. Every other fit runs
18
+ * through [FitGeometry], matching iOS's `FitResizeProcessor`.
19
+ *
20
+ * It runs before [BlurTransformation] and rounded corners so their pixel units refer to the final
21
+ * bitmap.
16
22
  */
17
23
  class ResizeTransformation(
18
24
  private val width: Int,
19
25
  private val height: Int,
26
+ private val fit: ResizeFit = ResizeFit.COVER,
27
+ private val allowUpscale: Boolean = true,
20
28
  ) : Transformation() {
21
29
 
22
30
  init {
23
31
  require(width > 0 && height > 0) { "width and height must be > 0." }
24
32
  }
25
33
 
26
- override val cacheKey = "${ResizeTransformation::class.java.name}-$width-$height"
34
+ private val isDefaultFit: Boolean
35
+ get() = fit == ResizeFit.COVER && allowUpscale
36
+
37
+ // The default keeps its original key so results cached by earlier versions stay valid.
38
+ override val cacheKey = buildString {
39
+ append(ResizeTransformation::class.java.name)
40
+ .append('-')
41
+ .append(width)
42
+ .append('-')
43
+ .append(height)
44
+ if (fit != ResizeFit.COVER) append('-').append(fit.name.lowercase())
45
+ if (!allowUpscale) append("-noupscale")
46
+ }
27
47
 
28
48
  override suspend fun transform(input: Bitmap, size: Size): Bitmap {
29
49
  val softwareInput =
30
50
  if (input.config == Bitmap.Config.HARDWARE) input.copy(Bitmap.Config.ARGB_8888, false)
31
51
  else input
32
- if (softwareInput.width == width && softwareInput.height == height) return softwareInput
52
+ return if (isDefaultFit) cover(softwareInput) else fitted(softwareInput)
53
+ }
54
+
55
+ private fun cover(input: Bitmap): Bitmap {
56
+ if (input.width == width && input.height == height) return input
33
57
 
34
- val scale = max(width.toFloat() / softwareInput.width, height.toFloat() / softwareInput.height)
58
+ val scale = max(width.toFloat() / input.width, height.toFloat() / input.height)
35
59
  val matrix =
36
60
  Matrix().apply {
37
61
  setScale(scale, scale)
38
- postTranslate(
39
- (width - softwareInput.width * scale) / 2f,
40
- (height - softwareInput.height * scale) / 2f,
62
+ postTranslate((width - input.width * scale) / 2f, (height - input.height * scale) / 2f)
63
+ }
64
+ return draw(input, width, height, matrix)
65
+ }
66
+
67
+ private fun fitted(input: Bitmap): Bitmap {
68
+ val geometry = FitGeometry.of(input.width, input.height, width, height, fit, allowUpscale)
69
+ // `center` of a source that fits the box, or `contain` without upscaling
70
+ // of a small source: the bitmap is already the output.
71
+ if (geometry.isIdentity) return input
72
+
73
+ val matrix =
74
+ Matrix().apply {
75
+ // Scale to the rounded size exactly, so the drawn extent is whole pixels.
76
+ setScale(
77
+ geometry.scaledWidth.toFloat() / input.width,
78
+ geometry.scaledHeight.toFloat() / input.height,
41
79
  )
80
+ postTranslate(geometry.originX.toFloat(), geometry.originY.toFloat())
42
81
  }
43
- val config = softwareInput.config ?: Bitmap.Config.ARGB_8888
44
- val output = Bitmap.createBitmap(width, height, config)
82
+ return draw(input, geometry.outputWidth, geometry.outputHeight, matrix)
83
+ }
84
+
85
+ private fun draw(input: Bitmap, outputWidth: Int, outputHeight: Int, matrix: Matrix): Bitmap {
86
+ val config = input.config ?: Bitmap.Config.ARGB_8888
87
+ val output = Bitmap.createBitmap(outputWidth, outputHeight, config)
45
88
  Canvas(output)
46
- .drawBitmap(softwareInput, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
89
+ .drawBitmap(input, matrix, Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG))
47
90
  return output
48
91
  }
49
92
 
50
93
  override fun equals(other: Any?): Boolean {
51
94
  if (this === other) return true
52
- return other is ResizeTransformation && width == other.width && height == other.height
95
+ return other is ResizeTransformation &&
96
+ width == other.width &&
97
+ height == other.height &&
98
+ fit == other.fit &&
99
+ allowUpscale == other.allowUpscale
53
100
  }
54
101
 
55
- override fun hashCode(): Int = 31 * width + height
102
+ override fun hashCode(): Int {
103
+ var result = 31 * width + height
104
+ result = 31 * result + fit.hashCode()
105
+ result = 31 * result + allowUpscale.hashCode()
106
+ return result
107
+ }
56
108
 
57
- override fun toString() = "ResizeTransformation(width=$width, height=$height)"
109
+ override fun toString() =
110
+ "ResizeTransformation(width=$width, height=$height, fit=$fit, allowUpscale=$allowUpscale)"
58
111
  }
@@ -0,0 +1,127 @@
1
+ //
2
+ // FitResizeProcessor.swift
3
+ // NitroImagePipeline
4
+ //
5
+ // Resizes into the `ResizeOptions` box under any `fit` other than the
6
+ // default. The default — `cover` with upscaling — stays on Nuke's own
7
+ // `ImageProcessors.Resize(.aspectFill, crop: true)`, so its output bytes and
8
+ // cache keys are exactly what they were before `fit` existed.
9
+ //
10
+
11
+ import CoreGraphics
12
+ import Foundation
13
+ import Nuke
14
+ import UIKit
15
+
16
+ /// The resize geometry both platforms implement — Android's port is
17
+ /// `transform/FitGeometry.kt`, and `example/__tests__/fit-geometry.ts` is the
18
+ /// copy the harness checks both against. With a `w` × `h` source and a
19
+ /// `W` × `H` box:
20
+ ///
21
+ /// ```
22
+ /// cover : sx = sy = max(W/w, H/h)
23
+ /// contain : sx = sy = min(W/w, H/h)
24
+ /// stretch : sx = W/w ; sy = H/h
25
+ /// center : sx = sy = 1
26
+ /// if !allowUpscale: sx = min(sx, 1) ; sy = min(sy, 1)
27
+ /// scaled = (round(w·sx), round(h·sy))
28
+ /// output = (min(W, scaled.w), min(H, scaled.h))
29
+ /// origin = trunc((output − scaled) / 2) — a centred, whole-pixel crop
30
+ /// ```
31
+ ///
32
+ /// The source is drawn scaled to `scaled` at `origin` on an `output`-sized
33
+ /// canvas. Rounding is half-away-from-zero (`.rounded()` here,
34
+ /// `roundToInt()` in Kotlin — identical for the positive values involved).
35
+ struct FitGeometry: Equatable {
36
+ let source: CGSize
37
+ let scaled: CGSize
38
+ let output: CGSize
39
+ let origin: CGPoint
40
+
41
+ init(source: CGSize, box: CGSize, fit: ResizeFit, allowUpscale: Bool) {
42
+ let boxWidth = box.width.rounded()
43
+ let boxHeight = box.height.rounded()
44
+ var scaleX: CGFloat
45
+ var scaleY: CGFloat
46
+ switch fit {
47
+ case .cover:
48
+ let scale = max(boxWidth / source.width, boxHeight / source.height)
49
+ scaleX = scale
50
+ scaleY = scale
51
+ case .contain:
52
+ let scale = min(boxWidth / source.width, boxHeight / source.height)
53
+ scaleX = scale
54
+ scaleY = scale
55
+ case .stretch:
56
+ scaleX = boxWidth / source.width
57
+ scaleY = boxHeight / source.height
58
+ case .center:
59
+ scaleX = 1
60
+ scaleY = 1
61
+ }
62
+ if !allowUpscale {
63
+ scaleX = min(scaleX, 1)
64
+ scaleY = min(scaleY, 1)
65
+ }
66
+ let scaledWidth = max((source.width * scaleX).rounded(), 1)
67
+ let scaledHeight = max((source.height * scaleY).rounded(), 1)
68
+ let outputWidth = min(boxWidth, scaledWidth)
69
+ let outputHeight = min(boxHeight, scaledHeight)
70
+ self.source = source
71
+ scaled = CGSize(width: scaledWidth, height: scaledHeight)
72
+ output = CGSize(width: outputWidth, height: outputHeight)
73
+ origin = CGPoint(
74
+ x: ((outputWidth - scaledWidth) / 2).rounded(.towardZero),
75
+ y: ((outputHeight - scaledHeight) / 2).rounded(.towardZero)
76
+ )
77
+ }
78
+
79
+ /// Whether the source is already the output — nothing to draw. Both
80
+ /// sizes are compared with the *source*: `scaled == output` alone also
81
+ /// holds for every plain downscale (nothing is cropped), which is not an
82
+ /// identity.
83
+ var isIdentity: Bool { scaled == source && output == source }
84
+ }
85
+
86
+ /// Nuke processor for the non-default fits. Runs before the blur and corner
87
+ /// processors, like `ImageProcessors.Resize`, so their pixel units refer to
88
+ /// the produced bitmap. Sizes are in points of the image, which the pipeline
89
+ /// decodes at scale 1 (1 pt = 1 px) — the same convention as
90
+ /// `RoundedCornersProcessor`.
91
+ struct FitResizeProcessor: ImageProcessing {
92
+ let width: Double
93
+ let height: Double
94
+ let fit: ResizeFit
95
+ let allowUpscale: Bool
96
+
97
+ var identifier: String {
98
+ "com.nitroimagepipeline.resize?w=\(width),h=\(height),fit=\(fit.stringValue),upscale=\(allowUpscale)"
99
+ }
100
+
101
+ var hashableIdentifier: AnyHashable { identifier }
102
+
103
+ func process(_ image: PlatformImage) -> PlatformImage? {
104
+ let source = image.size
105
+ guard source.width > 0, source.height > 0 else { return image }
106
+ let geometry = FitGeometry(
107
+ source: source,
108
+ box: CGSize(width: width, height: height),
109
+ fit: fit,
110
+ allowUpscale: allowUpscale
111
+ )
112
+ // `center` of a source that fits the box, or `contain` without
113
+ // upscaling of a small source: the bitmap is already the output.
114
+ if geometry.isIdentity { return image }
115
+
116
+ let format = UIGraphicsImageRendererFormat()
117
+ format.scale = image.scale
118
+ format.opaque = false
119
+ // See RoundedCornersProcessor: the extended range doubles the bytes
120
+ // of an 8-bit source for nothing.
121
+ format.preferredRange = .standard
122
+
123
+ return UIGraphicsImageRenderer(size: geometry.output, format: format).image { _ in
124
+ image.draw(in: CGRect(origin: geometry.origin, size: geometry.scaled))
125
+ }
126
+ }
127
+ }
@@ -198,26 +198,46 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
198
198
  }
199
199
  }
200
200
 
201
- /// The target size in pixels, or `nil` when no (valid) resize was requested.
202
- private static func resizeSize(for options: Options?) -> CGSize? {
201
+ /// The requested resize, or `nil` when there is no (valid) one.
202
+ private static func requestedResize(for options: Options?) -> ResizeOptions? {
203
203
  guard let resize = options?.resize, resize.width > 0, resize.height > 0 else {
204
204
  return nil
205
205
  }
206
- return CGSize(width: resize.width, height: resize.height)
206
+ return resize
207
+ }
208
+
209
+ private static func fit(of resize: ResizeOptions) -> ResizeFit { resize.fit ?? .cover }
210
+
211
+ private static func allowsUpscale(_ resize: ResizeOptions) -> Bool { resize.allowUpscale ?? true }
212
+
213
+ /// The resize as it behaved before `fit` existed — aspect-fill, crop,
214
+ /// upscale — which keeps Nuke's own processor (and its cache keys).
215
+ private static func isDefaultFit(_ resize: ResizeOptions) -> Bool {
216
+ fit(of: resize) == .cover && allowsUpscale(resize)
207
217
  }
208
218
 
209
219
  private static func processors(for options: Options?) -> [any ImageProcessing] {
210
220
  var processors: [any ImageProcessing] = []
211
221
  // Resize first: blur sigma and corner radii are defined in pixels
212
222
  // of the bitmap they run on, so they must see the final size.
213
- if let size = resizeSize(for: options) {
214
- processors.append(ImageProcessors.Resize(
215
- size: size,
216
- unit: .pixels,
217
- contentMode: .aspectFill,
218
- crop: true,
219
- upscale: true
220
- ))
223
+ if let resize = requestedResize(for: options) {
224
+ let size = CGSize(width: resize.width, height: resize.height)
225
+ if isDefaultFit(resize) {
226
+ processors.append(ImageProcessors.Resize(
227
+ size: size,
228
+ unit: .pixels,
229
+ contentMode: .aspectFill,
230
+ crop: true,
231
+ upscale: true
232
+ ))
233
+ } else {
234
+ processors.append(FitResizeProcessor(
235
+ width: size.width,
236
+ height: size.height,
237
+ fit: fit(of: resize),
238
+ allowUpscale: allowsUpscale(resize)
239
+ ))
240
+ }
221
241
  }
222
242
  if let blur = options?.blur, blur > 0 {
223
243
  processors.append(GaussianBlurProcessor(sigma: blur))
@@ -242,12 +262,18 @@ class HybridNitroImagePipeline: HybridNitroImagePipelineSpec {
242
262
  // resolution — a 48 MP photo displayed as a 300 pt card would
243
263
  // otherwise decompress to ~190 MB before Resize shrinks it.
244
264
  // Matches Android, where the request's size() drives subsampling;
245
- // the exact size and crop still come from the Resize processor.
246
- if let size = resizeSize(for: options) {
265
+ // the exact size and crop still come from the resize processor.
266
+ // `contain` decodes aspect-fit: its output follows the smaller scale,
267
+ // and an aspect-fill decode of a wide panorama into a small box would
268
+ // be near the source's full width before the processor shrinks it.
269
+ // Android's Scale.FIT subsampling bounds the decode the same way.
270
+ // `center` never scales, so it must see the source's own pixels: a
271
+ // subsampled decode would crop the wrong ones.
272
+ if let resize = requestedResize(for: options), fit(of: resize) != .center {
247
273
  imgRequest.thumbnail = ImageRequest.ThumbnailOptions(
248
- size: size,
274
+ size: CGSize(width: resize.width, height: resize.height),
249
275
  unit: .pixels,
250
- contentMode: .aspectFill
276
+ contentMode: fit(of: resize) == .contain ? .aspectFit : .aspectFill
251
277
  )
252
278
  }
253
279
  return imgRequest
@@ -21,6 +21,11 @@ import UIKit
21
21
  /// `ViewOptions` values are in points; this class converts them to the
22
22
  /// pixel-based `Options` of the shared request builder using the view's
23
23
  /// display scale, so cache keys match an equivalent `loadImage` call.
24
+ ///
25
+ /// The optional `onLoad`/`onError` callbacks report a view's load back to JS.
26
+ /// They are per request, not per loader: several views (and a recycled cell
27
+ /// re-attaching) each call them, and a view that requests twice reports twice.
28
+ /// `loadImage()` doesn't — it reports through its promise instead.
24
29
  class PipelineImageLoader: HybridImageLoaderSpec {
25
30
  private let url: String
26
31
  private let options: ViewOptions?
@@ -30,6 +35,14 @@ class PipelineImageLoader: HybridImageLoaderSpec {
30
35
  // to the main thread — every access happens inside a main-queue block.
31
36
  private var tasks: [ObjectIdentifier: Task<Void, Never>] = [:]
32
37
  private var pendingLayouts: [ObjectIdentifier: NSKeyValueObservation] = [:]
38
+ // Bumped synchronously by every `requestImage`/`dropImage` — the view
39
+ // calls both on the main thread, from its own lifecycle — so work queued
40
+ // by an earlier call can tell a later one superseded it before its
41
+ // main-queue block ran, and neither displays nor reports. `dropImage`
42
+ // only *queues* its cancellation, so without this a request queued just
43
+ // before a detach still runs first and, on a memory-cache hit, reports a
44
+ // load for a view that is about to be cleared.
45
+ private var generations: [ObjectIdentifier: Int] = [:]
33
46
 
34
47
  init(url: String, options: ViewOptions?) {
35
48
  self.url = url
@@ -55,7 +68,9 @@ class PipelineImageLoader: HybridImageLoaderSpec {
55
68
  }
56
69
 
57
70
  /// The point-based `ViewOptions` as pixel-based `Options`, resolved
58
- /// against `scale` and the target size in pixels.
71
+ /// against `scale` and the target size in pixels. The fit comes from the
72
+ /// explicit `resize` when it carries one, else from the top-level
73
+ /// `fit`/`allowUpscale`, which apply to the measured size.
59
74
  private func pixelOptions(scale: CGFloat, sizePx: CGSize?) -> Options {
60
75
  let cornerRadius = options?.cornerRadius.map { radius -> Variant_Double_CornerRadii in
61
76
  switch radius {
@@ -74,16 +89,43 @@ class PipelineImageLoader: HybridImageLoaderSpec {
74
89
  blur: options?.blur.map { $0 * scale },
75
90
  cache: options?.cache,
76
91
  cornerRadius: cornerRadius,
77
- resize: sizePx.map { ResizeOptions(width: Double($0.width), height: Double($0.height)) }
92
+ resize: sizePx.map {
93
+ ResizeOptions(
94
+ width: Double($0.width),
95
+ height: Double($0.height),
96
+ fit: explicitResizeOptions?.fit ?? options?.fit,
97
+ allowUpscale: explicitResizeOptions?.allowUpscale ?? options?.allowUpscale
98
+ )
99
+ }
78
100
  )
79
101
  }
80
102
 
81
- /// An explicit `resize` override (pixels), when set and valid.
82
- private var explicitResize: CGSize? {
103
+ /// The loaded image as the view should draw it. The pipeline decodes at
104
+ /// scale 1 (1 pt = 1 px); `.center` — the one content mode that draws an
105
+ /// image at its point size — would then show a `fit: 'center'` bitmap at
106
+ /// `scale`× its size on a Retina screen, while Android's
107
+ /// `ScaleType.CENTER` draws it pixel for pixel. Re-wrapping with the
108
+ /// display scale (a wrapper around the same CGImage, not a copy) makes
109
+ /// iOS draw 1 bitmap pixel per device pixel too; the scaling content
110
+ /// modes are unaffected by an image's scale.
111
+ private static func displayImage(_ image: UIImage, scale: CGFloat) -> UIImage {
112
+ guard image.scale != scale, let cgImage = image.cgImage else { return image }
113
+ return UIImage(cgImage: cgImage, scale: scale, orientation: image.imageOrientation)
114
+ }
115
+
116
+ /// The explicit `resize` override, when set and valid. An invalid one
117
+ /// (a non-positive dimension) is ignored as a whole — its `fit` and
118
+ /// `allowUpscale` included, as on Android — and the view is measured.
119
+ private var explicitResizeOptions: ResizeOptions? {
83
120
  guard let resize = options?.resize, resize.width > 0, resize.height > 0 else {
84
121
  return nil
85
122
  }
86
- return CGSize(width: resize.width, height: resize.height)
123
+ return resize
124
+ }
125
+
126
+ /// The explicit `resize` override's size (pixels), when set and valid.
127
+ private var explicitResize: CGSize? {
128
+ explicitResizeOptions.map { CGSize(width: $0.width, height: $0.height) }
87
129
  }
88
130
 
89
131
  // MARK: - ImageLoader
@@ -108,17 +150,22 @@ class PipelineImageLoader: HybridImageLoaderSpec {
108
150
  func requestImage(forView view: any HybridNitroImageViewSpec) throws {
109
151
  guard let nativeView = view as? NativeImageView else { return }
110
152
  let key = ObjectIdentifier(view)
153
+ let generation = supersedeWork(for: key)
111
154
  // Always hop (asynchronously) to main: `requestImage` fires while the
112
155
  // view is being mounted, and only after the current mounting
113
156
  // transaction finishes is its final frame guaranteed to be set.
114
157
  DispatchQueue.main.async {
115
- self.load(into: nativeView.imageView, key: key)
158
+ guard self.isCurrent(generation, for: key) else { return }
159
+ self.load(into: nativeView.imageView, key: key, generation: generation)
116
160
  }
117
161
  }
118
162
 
119
163
  func dropImage(forView view: any HybridNitroImageViewSpec) throws {
120
164
  guard let nativeView = view as? NativeImageView else { return }
121
165
  let key = ObjectIdentifier(view)
166
+ // Takes effect now, unlike the cancellation below: a request queued
167
+ // before this drop runs first, and must not load or report.
168
+ _ = supersedeWork(for: key)
122
169
  // Same queue as `requestImage`, so rapid attach/detach sequences
123
170
  // (list recycling) replay in call order.
124
171
  DispatchQueue.main.async {
@@ -129,6 +176,20 @@ class PipelineImageLoader: HybridImageLoaderSpec {
129
176
 
130
177
  // MARK: - Main-thread loading
131
178
 
179
+ /// Invalidates whatever is queued for `key` and returns the token
180
+ /// identifying this new piece of work. Main thread, like its callers.
181
+ private func supersedeWork(for key: ObjectIdentifier) -> Int {
182
+ // Never reset: a token must not be reused while an older block that
183
+ // holds it is still queued.
184
+ let generation = (generations[key] ?? 0) + 1
185
+ generations[key] = generation
186
+ return generation
187
+ }
188
+
189
+ private func isCurrent(_ generation: Int, for key: ObjectIdentifier) -> Bool {
190
+ return generations[key] == generation
191
+ }
192
+
132
193
  private func cancel(key: ObjectIdentifier) {
133
194
  tasks[key]?.cancel()
134
195
  tasks[key] = nil
@@ -136,18 +197,18 @@ class PipelineImageLoader: HybridImageLoaderSpec {
136
197
  pendingLayouts[key] = nil
137
198
  }
138
199
 
139
- private func load(into imageView: UIImageView, key: ObjectIdentifier) {
200
+ private func load(into imageView: UIImageView, key: ObjectIdentifier, generation: Int) {
140
201
  cancel(key: key)
141
202
 
142
203
  if let sizePx = explicitResize {
143
- start(into: imageView, key: key, sizePx: sizePx)
204
+ start(into: imageView, key: key, generation: generation, sizePx: sizePx)
144
205
  return
145
206
  }
146
207
 
147
208
  let bounds = imageView.bounds.size
148
209
  if bounds.width > 0, bounds.height > 0 {
149
210
  let scale = Self.displayScale(of: imageView)
150
- start(into: imageView, key: key, sizePx: CGSize(
211
+ start(into: imageView, key: key, generation: generation, sizePx: CGSize(
151
212
  width: bounds.width * scale,
152
213
  height: bounds.height * scale
153
214
  ))
@@ -160,14 +221,26 @@ class PipelineImageLoader: HybridImageLoaderSpec {
160
221
  DispatchQueue.main.async {
161
222
  guard let self, let imageView else { return }
162
223
  guard self.pendingLayouts[key] != nil else { return }
163
- self.load(into: imageView, key: key)
224
+ guard self.isCurrent(generation, for: key) else { return }
225
+ self.load(into: imageView, key: key, generation: generation)
164
226
  }
165
227
  }
166
228
  }
167
229
  }
168
230
 
169
- private func start(into imageView: UIImageView, key: ObjectIdentifier, sizePx: CGSize) {
170
- guard let imageUrl = HybridNitroImagePipeline.url(from: url) else { return }
231
+ private func start(
232
+ into imageView: UIImageView,
233
+ key: ObjectIdentifier,
234
+ generation: Int,
235
+ sizePx: CGSize
236
+ ) {
237
+ guard let imageUrl = HybridNitroImagePipeline.url(from: url) else {
238
+ // Android's loader hands a malformed URL to Coil, which reports it
239
+ // as a failed request; report it here too rather than returning
240
+ // silently and leaving `onError` waiting forever.
241
+ options?.onError?("Invalid URL: \(url)")
242
+ return
243
+ }
171
244
  let scale = Self.displayScale(of: imageView)
172
245
  let request = HybridNitroImagePipeline.makeRequest(
173
246
  url: imageUrl,
@@ -181,15 +254,32 @@ class PipelineImageLoader: HybridImageLoaderSpec {
181
254
  // bitmap is already in memory. The subscript honours the request's
182
255
  // `.disableMemoryCacheReads`, so `cache: 'disk'`/`'none'` still miss.
183
256
  if !request.options.contains(.disableMemoryCacheReads), let cached = pipeline.cache[request] {
184
- imageView.image = cached.image
257
+ imageView.image = Self.displayImage(cached.image, scale: scale)
258
+ notifyLoad(cached.image)
185
259
  return
186
260
  }
187
261
  // Cancelling the Task cancels Nuke's request; a finished task stays in
188
262
  // the map (cancelling it is a no-op) until `cancel` replaces it.
189
- tasks[key] = Task { @MainActor [weak imageView] in
190
- guard let image = try? await pipeline.image(for: request) else { return }
191
- guard !Task.isCancelled else { return }
192
- imageView?.image = image
263
+ tasks[key] = Task { @MainActor [weak self, weak imageView] in
264
+ do {
265
+ let image = try await pipeline.image(for: request)
266
+ guard !Task.isCancelled else { return }
267
+ guard self?.isCurrent(generation, for: key) == true else { return }
268
+ imageView?.image = Self.displayImage(image, scale: scale)
269
+ self?.notifyLoad(image)
270
+ } catch {
271
+ // A load the view cancelled by detaching is not a failure.
272
+ guard !Task.isCancelled, !(error is CancellationError) else { return }
273
+ guard self?.isCurrent(generation, for: key) == true else { return }
274
+ self?.options?.onError?(error.localizedDescription)
275
+ }
193
276
  }
194
277
  }
278
+
279
+ /// Reports the displayed bitmap's size in pixels, when the caller asked
280
+ /// for it. `UIImage.size` is in points, so it needs the image's own scale.
281
+ private func notifyLoad(_ image: UIImage) {
282
+ guard let onLoad = options?.onLoad else { return }
283
+ onLoad(image.size.width * image.scale, image.size.height * image.scale)
284
+ }
195
285
  }
@@ -25,8 +25,9 @@ var _jsxRuntime = require("react/jsx-runtime");
25
25
  * the same pipeline and caches as `useImage`/`preLoadImage`.
26
26
  *
27
27
  * Compared to `PipelineImage`:
28
- * - No `onLoad`/`onError` callbacks the loaded `Image` never crosses into
29
- * JS. Use `PipelineImage` (or `useImage`) when you need them.
28
+ * - `onLoad` reports the bitmap's pixel size rather than the `Image` itself,
29
+ * which never crosses into JS, and can fire more than once for a view. Use
30
+ * `PipelineImage` (or `useImage`) when you need the `Image`.
30
31
  * - The bitmap is loaded once at the size the view first has; if the view is
31
32
  * resized later, the bitmap scales with it instead of reloading.
32
33
  * @example
@@ -44,16 +45,29 @@ const NativePipelineImage = exports.NativePipelineImage = /*#__PURE__*/(0, _reac
44
45
  cornerRadius,
45
46
  cache,
46
47
  resize,
48
+ fit,
49
+ allowUpscale,
50
+ onLoad,
51
+ onError,
47
52
  style,
53
+ resizeMode,
48
54
  ...viewProps
49
55
  }, ref) {
50
56
  // Same precedence as PipelineImage: an explicit prop wins over style.
51
57
  const effectiveCornerRadius = cornerRadius ?? (0, _resizeForStyle.cornerRadiusForStyle)(style);
58
+ // Inline arrows are fine here — the hook keeps the callbacks in a ref, so a
59
+ // new identity doesn't recreate the loader (which would reload the image).
52
60
  const loader = (0, _usePipelineImageLoader.usePipelineImageLoader)(url, {
53
61
  blur,
54
62
  cornerRadius: effectiveCornerRadius,
55
63
  cache,
56
- resize
64
+ resize,
65
+ // Same four values as the view's resizeMode; an explicit `fit` decouples
66
+ // them.
67
+ fit: fit ?? resizeMode,
68
+ allowUpscale,
69
+ onLoad,
70
+ onError
57
71
  });
58
72
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeNitroImage.NativeNitroImage
59
73
  // Before the spread so a caller-provided recyclingKey wins.
@@ -62,6 +76,7 @@ const NativePipelineImage = exports.NativePipelineImage = /*#__PURE__*/(0, _reac
62
76
  ...viewProps,
63
77
  ref: ref,
64
78
  style: style,
79
+ resizeMode: resizeMode,
65
80
  image: loader
66
81
  });
67
82
  });
@@ -1 +1 @@
1
- {"version":3,"names":["_react","require","_reactNativeNitroImage","_resizeForStyle","_resolveImageSource","_usePipelineImageLoader","_jsxRuntime","NativePipelineImage","exports","forwardRef","url","blur","cornerRadius","cache","resize","style","viewProps","ref","effectiveCornerRadius","cornerRadiusForStyle","loader","usePipelineImageLoader","jsx","NativeNitroImage","recyclingKey","recyclingKeyFor","image","displayName"],"sourceRoot":"../../src","sources":["NativePipelineImage.tsx"],"mappings":";;;;;;AAAA,IAAAA,MAAA,GAAAC,OAAA;AAEA,IAAAC,sBAAA,GAAAD,OAAA;AAEA,IAAAE,eAAA,GAAAF,OAAA;AACA,IAAAG,mBAAA,GAAAH,OAAA;AAMA,IAAAI,uBAAA,GAAAJ,OAAA;AAAkE,IAAAK,WAAA,GAAAL,OAAA;AAKlE;AACA;AACA;AACA;AACA;;AAmCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,MAAMM,mBAAmB,GAAAC,OAAA,CAAAD,mBAAA,gBAAG,IAAAE,iBAAU,EAG3C,SAASF,mBAAmBA,CAC5B;EAAEG,GAAG;EAAEC,IAAI;EAAEC,YAAY;EAAEC,KAAK;EAAEC,MAAM;EAAEC,KAAK;EAAE,GAAGC;AAAU,CAAC,EAC/DC,GAAG,EACH;EACA;EACA,MAAMC,qBAAqB,GAAGN,YAAY,IAAI,IAAAO,oCAAoB,EAACJ,KAAK,CAAC;EACzE,MAAMK,MAAM,GAAG,IAAAC,8CAAsB,EAACX,GAAG,EAAE;IACzCC,IAAI;IACJC,YAAY,EAAEM,qBAAqB;IACnCL,KAAK;IACLC;EACF,CAAC,CAAC;EAEF,oBACE,IAAAR,WAAA,CAAAgB,GAAA,EAACpB,sBAAA,CAAAqB;EACC;EAAA;IACAC,YAAY,EAAE,IAAAC,mCAAe,EAACf,GAAG,CAAE;IAAA,GAC/BM,SAAS;IACbC,GAAG,EAAEA,GAAI;IACTF,KAAK,EAAEA,KAAM;IACbW,KAAK,EAAEN;EAAO,CACf,CAAC;AAEN,CAAC,CAAC;;AAEF;AACA;AACAb,mBAAmB,CAACoB,WAAW,GAAG,qBAAqB","ignoreList":[]}
1
+ {"version":3,"names":["_react","require","_reactNativeNitroImage","_resizeForStyle","_resolveImageSource","_usePipelineImageLoader","_jsxRuntime","NativePipelineImage","exports","forwardRef","url","blur","cornerRadius","cache","resize","fit","allowUpscale","onLoad","onError","style","resizeMode","viewProps","ref","effectiveCornerRadius","cornerRadiusForStyle","loader","usePipelineImageLoader","jsx","NativeNitroImage","recyclingKey","recyclingKeyFor","image","displayName"],"sourceRoot":"../../src","sources":["NativePipelineImage.tsx"],"mappings":";;;;;;AAAA,IAAAA,MAAA,GAAAC,OAAA;AAEA,IAAAC,sBAAA,GAAAD,OAAA;AAEA,IAAAE,eAAA,GAAAF,OAAA;AACA,IAAAG,mBAAA,GAAAH,OAAA;AAOA,IAAAI,uBAAA,GAAAJ,OAAA;AAAkE,IAAAK,WAAA,GAAAL,OAAA;AAKlE;AACA;AACA;AACA;AACA;;AA2EA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,MAAMM,mBAAmB,GAAAC,OAAA,CAAAD,mBAAA,gBAAG,IAAAE,iBAAU,EAG3C,SAASF,mBAAmBA,CAC5B;EACEG,GAAG;EACHC,IAAI;EACJC,YAAY;EACZC,KAAK;EACLC,MAAM;EACNC,GAAG;EACHC,YAAY;EACZC,MAAM;EACNC,OAAO;EACPC,KAAK;EACLC,UAAU;EACV,GAAGC;AACL,CAAC,EACDC,GAAG,EACH;EACA;EACA,MAAMC,qBAAqB,GAAGX,YAAY,IAAI,IAAAY,oCAAoB,EAACL,KAAK,CAAC;EACzE;EACA;EACA,MAAMM,MAAM,GAAG,IAAAC,8CAAsB,EAAChB,GAAG,EAAE;IACzCC,IAAI;IACJC,YAAY,EAAEW,qBAAqB;IACnCV,KAAK;IACLC,MAAM;IACN;IACA;IACAC,GAAG,EAAEA,GAAG,IAAIK,UAAU;IACtBJ,YAAY;IACZC,MAAM;IACNC;EACF,CAAC,CAAC;EAEF,oBACE,IAAAZ,WAAA,CAAAqB,GAAA,EAACzB,sBAAA,CAAA0B;EACC;EAAA;IACAC,YAAY,EAAE,IAAAC,mCAAe,EAACpB,GAAG,CAAE;IAAA,GAC/BW,SAAS;IACbC,GAAG,EAAEA,GAAI;IACTH,KAAK,EAAEA,KAAM;IACbC,UAAU,EAAEA,UAAW;IACvBW,KAAK,EAAEN;EAAO,CACf,CAAC;AAEN,CAAC,CAAC;;AAEF;AACA;AACAlB,mBAAmB,CAACyB,WAAW,GAAG,qBAAqB","ignoreList":[]}
@@ -61,10 +61,13 @@ const PipelineImage = exports.PipelineImage = /*#__PURE__*/(0, _react.forwardRef
61
61
  blur = 0,
62
62
  cornerRadius,
63
63
  cache,
64
+ fit,
65
+ allowUpscale,
64
66
  onLoad,
65
67
  onError,
66
68
  style,
67
69
  onLayout,
70
+ resizeMode,
68
71
  ...viewProps
69
72
  }, ref) {
70
73
  const scale = _reactNative.PixelRatio.get();
@@ -72,7 +75,15 @@ const PipelineImage = exports.PipelineImage = /*#__PURE__*/(0, _react.forwardRef
72
75
  const [layoutSize, setLayoutSize] = (0, _react.useState)(undefined);
73
76
  // A numeric style is what the caller declared, so it wins and starts the
74
77
  // request a frame earlier; the measured layout is the fallback.
75
- const resize = styleSize ?? layoutSize;
78
+ const size = styleSize ?? layoutSize;
79
+ // The view's resizeMode and the bitmap's fit are the same four values;
80
+ // an explicit `fit` decouples them.
81
+ const effectiveFit = fit ?? resizeMode;
82
+ const resize = size ? {
83
+ ...size,
84
+ fit: effectiveFit,
85
+ allowUpscale
86
+ } : undefined;
76
87
  // Same precedence: an explicit prop wins over what style implies.
77
88
  const effectiveCornerRadius = cornerRadius ?? (0, _resizeForStyle.cornerRadiusForStyle)(style) ?? 0;
78
89
  const {
@@ -115,6 +126,7 @@ const PipelineImage = exports.PipelineImage = /*#__PURE__*/(0, _react.forwardRef
115
126
  ...viewProps,
116
127
  ref: ref,
117
128
  style: style,
129
+ resizeMode: resizeMode,
118
130
  onLayout: handleLayout,
119
131
  image: image
120
132
  });