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) } }