diff --git a/example/__tests__/placeholder.harness.tsx b/example/__tests__/placeholder.harness.tsx
new file mode 100644
index 00000000..f35fdee6
--- /dev/null
+++ b/example/__tests__/placeholder.harness.tsx
@@ -0,0 +1,88 @@
+import { screen } from '@react-native-harness/ui'
+import { View } from 'react-native'
+import { describe, expect, it, render, waitFor } from 'react-native-harness'
+import { type Image, Images, NativeNitroImage } from 'react-native-nitro-image'
+import { WebImages } from 'react-native-nitro-web-image'
+
+const RED = { r: 1, g: 0, b: 0, a: 1 }
+
+const REAL_IMAGE_URL = 'https://picsum.photos/seed/nitro-placeholder/80'
+
+/** Center pixel of a decoded raw buffer with its 4 channels sorted, so the
+ * comparison is invariant to channel order (RGBA/BGRA/ARGB) and alpha position. */
+function centerPixel(raw: {
+ buffer: ArrayBuffer
+ width: number
+ height: number
+}) {
+ const px = new Uint8Array(raw.buffer)
+ const i =
+ (Math.floor(raw.height / 2) * raw.width + Math.floor(raw.width / 2)) * 4
+ return [px[i], px[i + 1], px[i + 2], px[i + 3]].sort((a, b) => a - b)
+}
+
+/** Center pixel of a harness screenshot (PNG bytes), decoded via the library under test. */
+function screenshotCenterPixel(shot: { data: Uint8Array }) {
+ const image = Images.loadFromEncodedImageData({
+ buffer: shot.data.buffer as ArrayBuffer,
+ width: 0,
+ height: 0,
+ imageFormat: 'png',
+ })
+ return centerPixel(image.toRawPixelData())
+}
+
+const dist = (a: readonly number[], b: readonly number[]) =>
+ a.reduce((sum, v, i) => sum + Math.abs(v - b[i]), 0)
+
+describe('NitroImage view - placeholder', () => {
+ it('paints the placeholder until an image is set, then swaps to it', async () => {
+ const placeholder = Images.createBlankImage(80, 80, true, RED)
+ const placeholderPixel = centerPixel(placeholder.toRawPixelData())
+
+ // Decode up front so the swap is driven by the re-render, not a network race.
+ const realImage = await WebImages.loadFromURLAsync(REAL_IMAGE_URL, {
+ allowHardware: false,
+ })
+
+ function Tile({ image }: { image?: Image }) {
+ return (
+
+
+
+ )
+ }
+
+ // With no image, the native view shows the placeholder (painted async, so poll).
+ await render()
+ await waitFor(
+ async () => {
+ const tile = await screen.findByTestId('placeholder-tile')
+ const shot = await screen.screenshot(tile)
+ if (shot == null) throw new Error('screenshot returned null')
+ expect(
+ dist(screenshotCenterPixel(shot), placeholderPixel),
+ ).toBeLessThan(60)
+ },
+ { timeout: 5000 },
+ )
+
+ // Setting the image paints it over the placeholder: center no longer matches.
+ await render()
+ await waitFor(
+ async () => {
+ const tile = await screen.findByTestId('placeholder-tile')
+ const shot = await screen.screenshot(tile)
+ if (shot == null) throw new Error('screenshot returned null')
+ expect(
+ dist(screenshotCenterPixel(shot), placeholderPixel),
+ ).toBeGreaterThan(60)
+ },
+ { timeout: 5000 },
+ )
+ })
+})
diff --git a/example/__tests__/use-local-image.harness.tsx b/example/__tests__/use-local-image.harness.tsx
new file mode 100644
index 00000000..efba9cf0
--- /dev/null
+++ b/example/__tests__/use-local-image.harness.tsx
@@ -0,0 +1,96 @@
+import { screen } from '@react-native-harness/ui'
+import { Text, View } from 'react-native'
+import { describe, expect, it, render, waitFor } from 'react-native-harness'
+import {
+ Images,
+ type LocalImageSource,
+ useLocalImage,
+} from 'react-native-nitro-image'
+
+const RED = { r: 1, g: 0, b: 0, a: 1 }
+
+function Probe({ source }: { source: LocalImageSource | undefined }) {
+ const { image, error } = useLocalImage(source)
+ if (error != null) {
+ return {error.message}
+ }
+ if (image != null) {
+ return (
+ loaded
+ )
+ }
+ return idle
+}
+
+describe('useLocalImage', () => {
+ it('is idle when source is undefined', async () => {
+ await render(
+
+
+ ,
+ )
+ const node = await screen.findByTestId('probe-idle')
+ expect(node).not.toBeNull()
+ })
+
+ it('resolves a pre-decoded Image source synchronously to itself', async () => {
+ const source = Images.createBlankImage(48, 32, true, RED)
+ await render(
+
+
+ ,
+ )
+ await waitFor(async () => {
+ const node = screen.queryByTestId('probe-image-48x32')
+ expect(node).not.toBeNull()
+ })
+ })
+
+ it('resolves an encodedImageData source to a decoded Image', async () => {
+ const source = Images.createBlankImage(
+ 20,
+ 10,
+ true,
+ RED,
+ ).toEncodedImageData('png')
+ await render(
+
+
+ ,
+ )
+ await waitFor(async () => {
+ const node = screen.queryByTestId('probe-image-20x10')
+ expect(node).not.toBeNull()
+ })
+ })
+
+ it('resolves a rawPixelData source to an Image of the requested size', async () => {
+ const width = 8
+ const height = 6
+ const buffer = new Uint8Array(width * height * 4)
+ for (let i = 0; i < width * height; i++) {
+ buffer[i * 4 + 0] = 255
+ buffer[i * 4 + 1] = 0
+ buffer[i * 4 + 2] = 0
+ buffer[i * 4 + 3] = 255
+ }
+ await render(
+
+
+ ,
+ )
+ await waitFor(async () => {
+ const node = screen.queryByTestId('probe-image-8x6')
+ expect(node).not.toBeNull()
+ })
+ })
+})
diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock
index e2a9e070..42e4e160 100644
--- a/example/ios/Podfile.lock
+++ b/example/ios/Podfile.lock
@@ -2281,7 +2281,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
HarnessUI: f439596aec93ff76765451f834c93f966d54e53e
- hermes-engine: ef561c35b1325a953b54456ddbd27ee0faf01ba4
+ hermes-engine: b0d99d8b69831141e3f5630b66c979d5541cc253
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
NitroImage: 33c4698f86b081bd3d0245223f34ea561b0934ea
NitroModules: 16bc17a076b12304d608f7c915b9d321f56dfc19
@@ -2294,7 +2294,7 @@ SPEC CHECKSUMS:
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
- React-Core-prebuilt: 5babb62ae79bf6d5e6444270747410c815d25442
+ React-Core-prebuilt: 982bcd2eaa2ba6f3b96bb0d870b5bbd5478d21b8
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
@@ -2357,7 +2357,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2
ReactCodegen: e5d55b301414d3cb48738d6630b434e59cf5cc57
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
- ReactNativeDependencies: 86f567dc5edd6f71876342f497cbdc0c1edac579
+ ReactNativeDependencies: a1701c87c7ddc647e01b8232332236bb022f2b56
RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87
RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
@@ -2366,4 +2366,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 8c90c25c7a6bc16ec7b3ed7968df16467ab0fc35
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageView.kt b/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageView.kt
index 672c3e2c..8aa76fe9 100644
--- a/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageView.kt
+++ b/packages/react-native-nitro-image/android/src/main/java/com/margelo/nitro/image/HybridImageView.kt
@@ -1,6 +1,7 @@
package com.margelo.nitro.image
import android.content.Context
+import android.graphics.Bitmap
import android.util.Log
import android.view.View
import android.widget.ImageView
@@ -43,6 +44,20 @@ class HybridImageView(context: Context): HybridNitroImageViewSpec(), RecyclableV
}
}
+ override var placeholder: HybridImageSpec? = null
+ set(value) {
+ field = value
+ uiScope.launch {
+ val bitmap = placeholderBitmap
+ if (imageView.drawable == null && bitmap != null) {
+ imageView.setImageBitmap(bitmap)
+ }
+ }
+ }
+
+ private val placeholderBitmap: Bitmap?
+ get() = (placeholder as? HybridImage)?.bitmap
+
override var recyclingKey: String? = null
set(value) {
resetImageBeforeLoad = field != value
@@ -84,8 +99,8 @@ class HybridImageView(context: Context): HybridNitroImageViewSpec(), RecyclableV
private fun onAppear() {
val imageLoader = image?.asSecondOrNull() ?: return
try {
- if (resetImageBeforeLoad) {
- imageView.setImageDrawable(null)
+ if (resetImageBeforeLoad || imageView.drawable == null) {
+ imageView.setImageBitmap(placeholderBitmap)
resetImageBeforeLoad = false
}
imageLoader.requestImage(this)
diff --git a/packages/react-native-nitro-image/ios/HybridImageView.swift b/packages/react-native-nitro-image/ios/HybridImageView.swift
index 96f47848..ee8d529f 100644
--- a/packages/react-native-nitro-image/ios/HybridImageView.swift
+++ b/packages/react-native-nitro-image/ios/HybridImageView.swift
@@ -32,6 +32,18 @@ class HybridImageView: HybridNitroImageViewSpec {
}
}
}
+ var placeholder: (any HybridImageSpec)? = nil {
+ didSet {
+ DispatchQueue.runOnMain {
+ if self.view.image == nil, let placeholderUIImage = self.placeholderUIImage {
+ self.view.image = placeholderUIImage
+ }
+ }
+ }
+ }
+ private var placeholderUIImage: UIImage? {
+ (placeholder as? NativeImage)?.uiImage
+ }
var recyclingKey: String? {
didSet {
resetImageBeforeLoad = recyclingKey != oldValue
@@ -64,8 +76,7 @@ class HybridImageView: HybridNitroImageViewSpec {
// Image Loader - trigger a load or drop
didSetImageLoader()
case nil:
- // No Image
- view.image = nil
+ view.image = placeholderUIImage
}
}
@@ -93,8 +104,8 @@ extension HybridImageView: ViewLifecycleDelegate {
func willShow() {
guard let imageLoader else { return }
- if resetImageBeforeLoad {
- view.image = nil
+ if resetImageBeforeLoad || view.image == nil {
+ view.image = placeholderUIImage
resetImageBeforeLoad = false
}
try? imageLoader.requestImage(forView: self)
diff --git a/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.cpp b/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.cpp
index cdb27588..2e83007f 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.cpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.cpp
@@ -83,6 +83,15 @@ namespace margelo::nitro::image {
static const auto method = _javaPart->javaClassStatic()->getMethod /* recyclingKey */)>("setRecyclingKey");
method(_javaPart, recyclingKey.has_value() ? jni::make_jstring(recyclingKey.value()) : nullptr);
}
+ std::optional> JHybridNitroImageViewSpec::getPlaceholder() {
+ static const auto method = _javaPart->javaClassStatic()->getMethod()>("getPlaceholder");
+ auto __result = method(_javaPart);
+ return __result != nullptr ? std::make_optional(__result->getJHybridImageSpec()) : std::nullopt;
+ }
+ void JHybridNitroImageViewSpec::setPlaceholder(const std::optional>& placeholder) {
+ static const auto method = _javaPart->javaClassStatic()->getMethod /* placeholder */)>("setPlaceholder");
+ method(_javaPart, placeholder.has_value() ? std::dynamic_pointer_cast(placeholder.value())->getJavaPart() : nullptr);
+ }
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.hpp b/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.hpp
index 42681e55..22c1d560 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.hpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/android/c++/JHybridNitroImageViewSpec.hpp
@@ -56,6 +56,8 @@ namespace margelo::nitro::image {
void setResizeMode(std::optional resizeMode) override;
std::optional getRecyclingKey() override;
void setRecyclingKey(const std::optional& recyclingKey) override;
+ std::optional> getPlaceholder() override;
+ void setPlaceholder(const std::optional>& placeholder) override;
public:
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/android/c++/views/JHybridNitroImageViewStateUpdater.cpp b/packages/react-native-nitro-image/nitrogen/generated/android/c++/views/JHybridNitroImageViewStateUpdater.cpp
index 81eb6e4c..c84ac8d4 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/android/c++/views/JHybridNitroImageViewStateUpdater.cpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/android/c++/views/JHybridNitroImageViewStateUpdater.cpp
@@ -49,6 +49,10 @@ void JHybridNitroImageViewStateUpdater::updateViewProps(jni::alias_refsetRecyclingKey(props->recyclingKey.value);
props->recyclingKey.isDirty = false;
}
+ if (props->placeholder.isDirty) {
+ hybridView->setPlaceholder(props->placeholder.value);
+ props->placeholder.isDirty = false;
+ }
// Update hybridRef if it changed
if (props->hybridRef.isDirty) {
diff --git a/packages/react-native-nitro-image/nitrogen/generated/android/kotlin/com/margelo/nitro/image/HybridNitroImageViewSpec.kt b/packages/react-native-nitro-image/nitrogen/generated/android/kotlin/com/margelo/nitro/image/HybridNitroImageViewSpec.kt
index 2781d821..a41416bb 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/android/kotlin/com/margelo/nitro/image/HybridNitroImageViewSpec.kt
+++ b/packages/react-native-nitro-image/nitrogen/generated/android/kotlin/com/margelo/nitro/image/HybridNitroImageViewSpec.kt
@@ -43,6 +43,12 @@ abstract class HybridNitroImageViewSpec: HybridView() {
@set:DoNotStrip
@set:Keep
abstract var recyclingKey: String?
+
+ @get:DoNotStrip
+ @get:Keep
+ @set:DoNotStrip
+ @set:Keep
+ abstract var placeholder: HybridImageSpec?
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/ios/NitroImage-Swift-Cxx-Bridge.hpp b/packages/react-native-nitro-image/nitrogen/generated/ios/NitroImage-Swift-Cxx-Bridge.hpp
index 9b216f61..26984207 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/ios/NitroImage-Swift-Cxx-Bridge.hpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/ios/NitroImage-Swift-Cxx-Bridge.hpp
@@ -613,5 +613,20 @@ namespace margelo::nitro::image::bridge::swift {
inline std::string get_std__optional_std__string_(const std::optional& optional) noexcept {
return optional.value();
}
+
+ // pragma MARK: std::optional>
+ /**
+ * Specialized version of `std::optional>`.
+ */
+ using std__optional_std__shared_ptr_HybridImageSpec__ = std::optional>;
+ inline std::optional> create_std__optional_std__shared_ptr_HybridImageSpec__(const std::shared_ptr& value) noexcept {
+ return std::optional>(value);
+ }
+ inline bool has_value_std__optional_std__shared_ptr_HybridImageSpec__(const std::optional>& optional) noexcept {
+ return optional.has_value();
+ }
+ inline std::shared_ptr get_std__optional_std__shared_ptr_HybridImageSpec__(const std::optional>& optional) noexcept {
+ return optional.value();
+ }
} // namespace margelo::nitro::image::bridge::swift
diff --git a/packages/react-native-nitro-image/nitrogen/generated/ios/c++/HybridNitroImageViewSpecSwift.hpp b/packages/react-native-nitro-image/nitrogen/generated/ios/c++/HybridNitroImageViewSpecSwift.hpp
index b794f603..2ad592c9 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/ios/c++/HybridNitroImageViewSpecSwift.hpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/ios/c++/HybridNitroImageViewSpecSwift.hpp
@@ -94,6 +94,13 @@ namespace margelo::nitro::image {
inline void setRecyclingKey(const std::optional& recyclingKey) noexcept override {
_swiftPart.setRecyclingKey(recyclingKey);
}
+ inline std::optional> getPlaceholder() noexcept override {
+ auto __result = _swiftPart.getPlaceholder();
+ return __result;
+ }
+ inline void setPlaceholder(const std::optional>& placeholder) noexcept override {
+ _swiftPart.setPlaceholder(placeholder);
+ }
public:
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm b/packages/react-native-nitro-image/nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm
index 176e7b23..a75147ac 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm
+++ b/packages/react-native-nitro-image/nitrogen/generated/ios/c++/views/HybridNitroImageViewComponent.mm
@@ -94,6 +94,11 @@ - (void) updateProps:(const std::shared_ptr&)props
swiftPart.setRecyclingKey(newViewProps.recyclingKey.value);
newViewProps.recyclingKey.isDirty = false;
}
+ // placeholder: optional
+ if (newViewProps.placeholder.isDirty) {
+ swiftPart.setPlaceholder(newViewProps.placeholder.value);
+ newViewProps.placeholder.isDirty = false;
+ }
swiftPart.afterUpdate();
diff --git a/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec.swift b/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec.swift
index 07709254..d760d8fd 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec.swift
+++ b/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec.swift
@@ -13,6 +13,7 @@ public protocol HybridNitroImageViewSpec_protocol: HybridObject, HybridView {
var image: Variant__any_HybridImageSpec___any_HybridImageLoaderSpec_? { get set }
var resizeMode: ResizeMode? { get set }
var recyclingKey: String? { get set }
+ var placeholder: (any HybridImageSpec)? { get set }
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec_cxx.swift b/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec_cxx.swift
index 7f878b7c..d03b1721 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec_cxx.swift
+++ b/packages/react-native-nitro-image/nitrogen/generated/ios/swift/HybridNitroImageViewSpec_cxx.swift
@@ -218,6 +218,37 @@ open class HybridNitroImageViewSpec_cxx {
}()
}
}
+
+ public final var placeholder: bridge.std__optional_std__shared_ptr_HybridImageSpec__ {
+ @inline(__always)
+ get {
+ return { () -> bridge.std__optional_std__shared_ptr_HybridImageSpec__ in
+ if let __unwrappedValue = self.__implementation.placeholder {
+ return bridge.create_std__optional_std__shared_ptr_HybridImageSpec__({ () -> bridge.std__shared_ptr_HybridImageSpec_ in
+ let __cxxWrapped = __unwrappedValue.getCxxWrapper()
+ return __cxxWrapped.getCxxPart()
+ }())
+ } else {
+ return .init()
+ }
+ }()
+ }
+ @inline(__always)
+ set {
+ self.__implementation.placeholder = { () -> (any HybridImageSpec)? in
+ if bridge.has_value_std__optional_std__shared_ptr_HybridImageSpec__(newValue) {
+ let __unwrapped = bridge.get_std__optional_std__shared_ptr_HybridImageSpec__(newValue)
+ return { () -> any HybridImageSpec in
+ let __unsafePointer = bridge.get_std__shared_ptr_HybridImageSpec_(__unwrapped)
+ let __instance = HybridImageSpec_cxx.fromUnsafe(__unsafePointer)
+ return __instance.getHybridImageSpec()
+ }()
+ } else {
+ return nil
+ }
+ }()
+ }
+ }
// Methods
public final func getView() -> UnsafeMutableRawPointer {
diff --git a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.cpp b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.cpp
index 04c55de6..65891019 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.cpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.cpp
@@ -20,6 +20,8 @@ namespace margelo::nitro::image {
prototype.registerHybridSetter("resizeMode", &HybridNitroImageViewSpec::setResizeMode);
prototype.registerHybridGetter("recyclingKey", &HybridNitroImageViewSpec::getRecyclingKey);
prototype.registerHybridSetter("recyclingKey", &HybridNitroImageViewSpec::setRecyclingKey);
+ prototype.registerHybridGetter("placeholder", &HybridNitroImageViewSpec::getPlaceholder);
+ prototype.registerHybridSetter("placeholder", &HybridNitroImageViewSpec::setPlaceholder);
});
}
diff --git a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.hpp b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.hpp
index 61e4e744..b8a422c2 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.hpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/HybridNitroImageViewSpec.hpp
@@ -61,6 +61,8 @@ namespace margelo::nitro::image {
virtual void setResizeMode(std::optional resizeMode) = 0;
virtual std::optional getRecyclingKey() = 0;
virtual void setRecyclingKey(const std::optional& recyclingKey) = 0;
+ virtual std::optional> getPlaceholder() = 0;
+ virtual void setPlaceholder(const std::optional>& placeholder) = 0;
public:
// Methods
diff --git a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.cpp b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.cpp
index cdae2e81..3d4c0e75 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.cpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.cpp
@@ -56,6 +56,16 @@ namespace margelo::nitro::image::views {
throw std::runtime_error(std::string("NitroImageView.recyclingKey: ") + exc.what());
}
}()),
+ placeholder([&]() -> CachedProp>> {
+ try {
+ const react::RawValue* rawValue = rawProps.at("placeholder", nullptr, nullptr);
+ if (rawValue == nullptr) return sourceProps.placeholder;
+ const auto& [runtime, value] = (std::pair)*rawValue;
+ return CachedProp>>::fromRawValue(*runtime, value, sourceProps.placeholder);
+ } catch (const std::exception& exc) {
+ throw std::runtime_error(std::string("NitroImageView.placeholder: ") + exc.what());
+ }
+ }()),
hybridRef([&]() -> CachedProp& /* ref */)>>> {
try {
const react::RawValue* rawValue = rawProps.at("hybridRef", nullptr, nullptr);
@@ -72,6 +82,7 @@ namespace margelo::nitro::image::views {
case hashString("image"): return true;
case hashString("resizeMode"): return true;
case hashString("recyclingKey"): return true;
+ case hashString("placeholder"): return true;
case hashString("hybridRef"): return true;
default: return false;
}
diff --git a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.hpp b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.hpp
index 91248b9e..f086ddf8 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.hpp
+++ b/packages/react-native-nitro-image/nitrogen/generated/shared/c++/views/HybridNitroImageViewComponent.hpp
@@ -49,6 +49,7 @@ namespace margelo::nitro::image::views {
CachedProp, std::shared_ptr>>> image;
CachedProp> resizeMode;
CachedProp> recyclingKey;
+ CachedProp>> placeholder;
CachedProp& /* ref */)>>> hybridRef;
private:
diff --git a/packages/react-native-nitro-image/nitrogen/generated/shared/json/NitroImageViewConfig.json b/packages/react-native-nitro-image/nitrogen/generated/shared/json/NitroImageViewConfig.json
index e376831b..c8d26754 100644
--- a/packages/react-native-nitro-image/nitrogen/generated/shared/json/NitroImageViewConfig.json
+++ b/packages/react-native-nitro-image/nitrogen/generated/shared/json/NitroImageViewConfig.json
@@ -7,6 +7,7 @@
"image": true,
"resizeMode": true,
"recyclingKey": true,
+ "placeholder": true,
"hybridRef": true
}
}
diff --git a/packages/react-native-nitro-image/src/NitroImage.tsx b/packages/react-native-nitro-image/src/NitroImage.tsx
index 004ad210..d31efd4b 100644
--- a/packages/react-native-nitro-image/src/NitroImage.tsx
+++ b/packages/react-native-nitro-image/src/NitroImage.tsx
@@ -4,12 +4,23 @@ import type { HostComponent } from 'react-native'
import type { AsyncImageSource } from './AsyncImageSource'
import { NativeNitroImage } from './NativeNitroImage'
import { useImageLoader } from './useImageLoader'
+import { type LocalImageSource, useLocalImage } from './useLocalImage'
type ReactProps = T extends HostComponent ? P : never
type NativeImageProps = ReactProps
-export interface NitroImageProps extends Omit {
+export interface NitroImageProps
+ extends Omit {
image: AsyncImageSource
+ /**
+ * An optional placeholder shown while {@linkcode image} is loading.
+ *
+ * Restricted to local sources (require, file path, raw/encoded data,
+ * resource, symbol, or a pre-decoded {@linkcode Image}) so the placeholder
+ * itself never fires a network request. Pass a `require(...)` asset or a
+ * blurhash/thumbhash decode for best results.
+ */
+ placeholder?: LocalImageSource
}
/**
@@ -30,7 +41,14 @@ export interface NitroImageProps extends Omit {
* }
* ```
*/
-export function NitroImage({ image, ...props }: NitroImageProps) {
+export function NitroImage({ image, placeholder, ...props }: NitroImageProps) {
const actualImage = useImageLoader(image)
- return
+ const { image: actualPlaceholder } = useLocalImage(placeholder)
+ return (
+
+ )
}
diff --git a/packages/react-native-nitro-image/src/index.ts b/packages/react-native-nitro-image/src/index.ts
index 3f8eb89c..2b6ce945 100644
--- a/packages/react-native-nitro-image/src/index.ts
+++ b/packages/react-native-nitro-image/src/index.ts
@@ -9,3 +9,4 @@ export type { ImageLoader } from './specs/ImageLoader.nitro'
export * from './useImage'
export * from './useImageLoader'
+export * from './useLocalImage'
diff --git a/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts b/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts
index 572a3a69..18466ec7 100644
--- a/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts
+++ b/packages/react-native-nitro-image/src/specs/ImageView.nitro.ts
@@ -55,6 +55,27 @@ export interface NativeNitroImageViewProps extends HybridViewProps {
* ```
*/
recyclingKey?: string
+ /**
+ * An {@linkcode Image} to display while the main {@linkcode image} is still
+ * loading, or whenever no main {@linkcode image} is currently shown
+ * (e.g. after a {@linkcode recyclingKey} change).
+ *
+ * The placeholder is only used until the first real Image is loaded;
+ * once the loader resolves, the real Image overwrites it.
+ *
+ * For best results, the placeholder should be a lightweight, already-decoded
+ * Image, e.g. a `require(...)` asset, a blurhash/thumbhash decode, or any
+ * other locally-available {@linkcode Image} instance.
+ * @default undefined
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+ placeholder?: Image
}
export interface NativeNitroImageViewMethods extends HybridViewMethods {
diff --git a/packages/react-native-nitro-image/src/useLocalImage.ts b/packages/react-native-nitro-image/src/useLocalImage.ts
new file mode 100644
index 00000000..51a7ae6b
--- /dev/null
+++ b/packages/react-native-nitro-image/src/useLocalImage.ts
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react'
+import { type AsyncImageSource, isHybridObject } from './AsyncImageSource'
+import { loadImage } from './loadImage'
+import { markHybridObject } from './markHybridObject'
+import type { Image } from './specs/Image.nitro'
+import type { ImageLoader } from './specs/ImageLoader.nitro'
+
+/**
+ * Same shape as {@linkcode AsyncImageSource} but with remote URLs and
+ * `ImageLoader`s excluded, so resolving the source never fires a network
+ * request. Useful for things like the `placeholder` prop on ``.
+ */
+export type LocalImageSource = Exclude<
+ AsyncImageSource,
+ { url: string } | ImageLoader
+>
+
+type Result =
+ | { image: undefined; error: undefined }
+ | { image: Image; error: undefined }
+ | { image: undefined; error: Error }
+
+/**
+ * Like {@linkcode useImage}, but only accepts local sources (no URLs, no
+ * `ImageLoader`s) and `undefined`.
+ */
+export function useLocalImage(source: LocalImageSource | undefined): Result {
+ const [result, setResult] = useState({
+ image: undefined,
+ error: undefined,
+ })
+
+ // biome-ignore lint: The dependencies array is a bit hacky.
+ useEffect(() => {
+ if (source == null) {
+ setResult({ image: undefined, error: undefined })
+ return
+ }
+ ;(async () => {
+ try {
+ const img = await loadImage(source)
+ // Tag with `__source` so React diffs the placeholder prop properly.
+ markHybridObject(img, source)
+ setResult({ image: img, error: undefined })
+ } catch (e) {
+ const error = e instanceof Error ? e : new Error(`${e}`)
+ setResult({ image: undefined, error })
+ }
+ })()
+ }, [
+ source == null
+ ? null
+ : isHybridObject(source)
+ ? source
+ : JSON.stringify(source),
+ ])
+
+ return result
+}
diff --git a/packages/react-native-nitro-web-image/android/src/main/java/com/margelo/nitro/web/image/HybridWebImageLoader.kt b/packages/react-native-nitro-web-image/android/src/main/java/com/margelo/nitro/web/image/HybridWebImageLoader.kt
index d4fd6b40..951b81b8 100644
--- a/packages/react-native-nitro-web-image/android/src/main/java/com/margelo/nitro/web/image/HybridWebImageLoader.kt
+++ b/packages/react-native-nitro-web-image/android/src/main/java/com/margelo/nitro/web/image/HybridWebImageLoader.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.widget.ImageView
import coil3.ImageLoader
import coil3.load
+import coil3.request.placeholder
import com.margelo.nitro.core.Promise
import com.margelo.nitro.image.HybridImageSpec
import com.margelo.nitro.image.HybridImageLoaderSpec
@@ -24,6 +25,7 @@ class HybridWebImageLoader(private val imageLoader: ImageLoader,
imageView.load(url, imageLoader) {
this.applyOptions(options)
+ placeholder(imageView.drawable)
}
}