Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions example/__tests__/placeholder.harness.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View testID="placeholder-tile" style={{ width: 80, height: 80 }}>
<NativeNitroImage
image={image}
placeholder={placeholder}
style={{ width: 80, height: 80 }}
/>
</View>
)
}

// With no image, the native view shows the placeholder (painted async, so poll).
await render(<Tile />)
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(<Tile image={realImage} />)
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 },
)
})
})
96 changes: 96 additions & 0 deletions example/__tests__/use-local-image.harness.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text testID="probe-error">{error.message}</Text>
}
if (image != null) {
return (
<Text testID={`probe-image-${image.width}x${image.height}`}>loaded</Text>
)
}
return <Text testID="probe-idle">idle</Text>
}

describe('useLocalImage', () => {
it('is idle when source is undefined', async () => {
await render(
<View>
<Probe source={undefined} />
</View>,
)
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(
<View>
<Probe source={source} />
</View>,
)
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(
<View>
<Probe source={{ encodedImageData: source }} />
</View>,
)
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(
<View>
<Probe
source={{
rawPixelData: {
buffer: buffer.buffer,
width,
height,
pixelFormat: 'RGBA',
},
}}
/>
</View>,
)
await waitFor(async () => {
const node = screen.queryByTestId('probe-image-8x6')
expect(node).not.toBeNull()
})
})
})
8 changes: 4 additions & 4 deletions example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2281,7 +2281,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
HarnessUI: f439596aec93ff76765451f834c93f966d54e53e
hermes-engine: ef561c35b1325a953b54456ddbd27ee0faf01ba4
hermes-engine: b0d99d8b69831141e3f5630b66c979d5541cc253
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
NitroImage: 33c4698f86b081bd3d0245223f34ea561b0934ea
NitroModules: 16bc17a076b12304d608f7c915b9d321f56dfc19
Expand All @@ -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
Expand Down Expand Up @@ -2357,7 +2357,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 25c9c516839be2c5e3d3344f95dc7da5f7e63fc2
ReactCodegen: e5d55b301414d3cb48738d6630b434e59cf5cc57
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeDependencies: 86f567dc5edd6f71876342f497cbdc0c1edac579
ReactNativeDependencies: a1701c87c7ddc647e01b8232332236bb022f2b56
RNFastImage: 462a183c4b0b6b26fdfd639e1ed6ba37536c3b87
RNScreens: 991cc417cd396602a6cf59a42139e5a9d91462a9
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
Expand All @@ -2366,4 +2366,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: 8c90c25c7a6bc16ec7b3ed7968df16467ab0fc35

COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 15 additions & 4 deletions packages/react-native-nitro-image/ios/HybridImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading