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.
- package/README.md +88 -13
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +51 -20
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +47 -8
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/FitGeometry.kt +104 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/PipelineRoundedCornersTransformation.kt +103 -0
- package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt +69 -16
- package/ios/FitResizeProcessor.swift +127 -0
- package/ios/HybridNitroImagePipeline.swift +41 -15
- package/ios/PipelineImageLoader.swift +107 -17
- package/lib/commonjs/NativePipelineImage.js +18 -3
- package/lib/commonjs/NativePipelineImage.js.map +1 -1
- package/lib/commonjs/PipelineImage.js +13 -1
- package/lib/commonjs/PipelineImage.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/resizeForStyle.js +3 -1
- package/lib/commonjs/resizeForStyle.js.map +1 -1
- package/lib/commonjs/useImage.js +6 -2
- package/lib/commonjs/useImage.js.map +1 -1
- package/lib/commonjs/usePipelineImageLoader.js +107 -9
- package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
- package/lib/module/NativePipelineImage.js +18 -3
- package/lib/module/NativePipelineImage.js.map +1 -1
- package/lib/module/PipelineImage.js +13 -1
- package/lib/module/PipelineImage.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/resizeForStyle.js +3 -1
- package/lib/module/resizeForStyle.js.map +1 -1
- package/lib/module/useImage.js +6 -2
- package/lib/module/useImage.js.map +1 -1
- package/lib/module/usePipelineImageLoader.js +108 -10
- package/lib/module/usePipelineImageLoader.js.map +1 -1
- package/lib/typescript/src/NativePipelineImage.d.ts +44 -3
- package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
- package/lib/typescript/src/PipelineImage.d.ts +28 -1
- package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/resizeForStyle.d.ts +3 -1
- package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +83 -8
- package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
- package/lib/typescript/src/useImage.d.ts +3 -2
- package/lib/typescript/src/useImage.d.ts.map +1 -1
- package/lib/typescript/src/usePipelineImageLoader.d.ts +3 -1
- package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
- package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
- package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
- package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
- package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
- package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
- package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
- package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
- package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
- package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
- package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
- package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
- package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
- package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
- package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
- package/package.json +1 -1
- package/src/NativePipelineImage.tsx +67 -3
- package/src/PipelineImage.tsx +39 -1
- package/src/index.ts +1 -0
- package/src/resizeForStyle.ts +3 -1
- package/src/specs/nitro-image-toolkit.nitro.ts +84 -8
- package/src/useImage.ts +13 -3
- package/src/usePipelineImageLoader.ts +115 -8
package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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() /
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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(
|
|
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 &&
|
|
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
|
|
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() =
|
|
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
|
|
202
|
-
private static func
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
246
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
///
|
|
82
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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(
|
|
170
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
* -
|
|
29
|
-
* JS
|
|
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;
|
|
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
|
|
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
|
});
|