@obitrain/react-native-image
v0.3.2
Published
Fast image component for React Native: Nitro view with Kingfisher (iOS) and Coil 3 (Android), decode-to-size, FastImage-compatible API
Readme
@obitrain/react-native-image
A fast image component for React Native, built as a Nitro view (react-native-nitro-modules) with
Kingfisher on iOS and Coil 3 on Android.
It exposes a FastImage-compatible API and adds what FastImage lacks:
decode-to-size — every image is decoded at the view's pixel size (ImageIO CGImageSourceCreateThumbnailAtIndex /
Coil size()), never at full resolution — plus prefetch() and transcode() APIs.
Requirements
- React Native 0.87.x, New Architecture (Fabric), bare RN or Expo prebuild.
react-native-nitro-modules0.36.x in the app (peer dependency>=0.35.0 <0.37.0).- iOS 15.1+, Xcode 16.4+ (tested on Xcode 26 / Swift 6.2), CocoaPods. Android minSdk 24, Kotlin 2.2+, NDK 27.
Installation
yarn add @obitrain/react-native-image # or: npm install @obitrain/react-native-image
cd ios && pod installAndroid autolinks. Kingfisher (~> 8.12) and Coil (3.4.0) are pulled in by the podspec / build.gradle; the package
ships its generated Nitro code, so no codegen step is needed. You can also pin a release tag
("@obitrain/react-native-image": "Obitrain/obi-image#v0.1.1") — each GitHub release carries the same tarball.
Usage
import { Image, prefetch } from '@obitrain/react-native-image';
<Image source={{ uri }} resizeMode="cover" style={{ width: 70, height: 50, borderRadius: 10 }} onError={() => setFallback(true)} />
<Image source={require('./icon.png')} tintColor="red" resizeMode="contain" style={{ width: 30, height: 30 }} />
<Image source={{ uri }} style={styles.card}>
<Pressable style={StyleSheet.absoluteFill} onPress={open} /> {/* children render above the image and are tappable */}
</Image>
<FlatList renderItem={({ item }) => <Image source={{ uri: item.uri }} recyclingKey={item.id} style={styles.thumb} />} />
await prefetch(nextScreen.map((r) => r.thumbUrl)); // warm the disk cache (iOS: memory too) before navigating| Prop | Type | Notes |
|---|---|---|
| source | { uri: string } | number (require()) | Bundled assets resolve to http (Metro), file:// (iOS release) or a drawable (Android release). |
| resizeMode | 'cover' (default) | 'contain' | 'center' | 'stretch' | Same names as FastImage / RN. |
| style | StyleProp<ViewStyle> | Applied to the wrapper View; borderRadius clips. Give a numeric width/height so the decode size is known at first render. |
| tintColor | ColorValue | Template-renders the image (icons). |
| onLoad | () => void | Fires after a local, cached, or network image loads successfully. |
| onError | () => void | Fires when the load fails (404, decode error, missing bundled asset). |
| recyclingKey | string | Set to the row id in lists so a recycled cell never shows the previous row's image. |
| children | ReactNode | Rendered as siblings above the native image view — overlays, buttons. |
| ...ViewProps | | testID, accessibilityLabel, pointerEvents, … go to the wrapper View. |
prefetch(urls: string[]): Promise<void> — fetches into the cache; per-URL failures are ignored (they just stay uncached).
transcode(uri, { maxSize = 2048, quality = 0.85 }): Promise<TranscodedImage> — re-encodes any decodable image (HEIC,
PNG, WebP, JPEG…) as a JPEG whose longest side is at most maxSize (never upscaled), for uploads to servers that only
take JPEG. EXIF orientation is applied, metadata (incl. GPS) is dropped and transparency is flattened onto white.
uri: file://, http(s)://, ph:// (iOS Photos), content:// (Android) or a require()d asset's
Image.resolveAssetSource(...).uri. Resolves to { uri, width, height, size } with a file:// URI in the
temporary/cache directory. HEIC needs Android 9+.
import { transcode } from '@obitrain/react-native-image';
const jpeg = await transcode(pickedPhoto.uri, { maxSize: 1024 });
upload({ uri: jpeg.uri, type: 'image/jpeg', name: 'image.jpg' });Not supported (yet): priority, cacheControl, headers, defaultSource, onLoad, animated GIF playback on iOS
(the first frame is shown), ph:// Photos URIs in <Image> (transcode() accepts them).
How it works
src/Image.tsx— JS wrapper with FastImage-shaped props. Ownsrequire()resolution, points→pixels (PixelRatio), and theView{overflow:'hidden'}+ absolute-fill native view + children layout that givesborderRadiusclipping and tappable overlays for free. Wrapped inReact.memo;onErroris memoized and wrapped with Nitro'scallback()(Nitro views require function props to be wrapped).src/ImageView.nitro.ts— the Nitro view spec (ObitrainImageView);src/ImagePrefetcher.nitro.ts— the prefetch module.ios/HybridImageView.swift—UIImageView+ KingfisherDownsamplingImageProcessor(pixel size,.scaleFactor(1),coverdecodes with 1.25× headroom so the short side still fills). Originals go to disk (.cacheOriginalImage) so a new size never re-downloads; the downsampled bitmap is memory-only (customCacheSerializerreturning no data).kf.setImagecancels the previous task per view;prepareForRecycleresets everything.android/.../HybridImageView.kt—AppCompatImageView+ a library-owned CoilImageLoader(ImageLoaders.kt: own disk cache dir, 10 % memory cache,allowRgb565) withsize(w,h)+Precision.INEXACT. Coil disposes the previous request per view.
Toolchain notes (RN 0.87 + nitro 0.36) — read before touching the native side
react-native-nitro-modules/nitrogenare pinned to 0.36.5. nitro 0.37 would make the first two workarounds below unnecessary; bump both together and re-runyarn nitrogen.- RN 0.87 requires Fabric component views to set
_propsin their constructor; nitrogen 0.36.x does not emit that.scripts/patch-nitrogen.mjspatches the generated*Component.mmafter everyyarn nitrogen. Without it the app aborts on first mount:RCTViewComponentView subclasses ... must setup _props instance variable. - Xcode 26 / Swift 6.2: nitrogen 0.36 emits
Bool(fromCxx:), rejected by Swift 6.2; the podspecscript_phaserewrites it. - Never name a Nitro view
ImageView: Fabric resolves that name to React Native's ownRCTImageComponentViewand silently renders core<Image>instead of yours. HenceObitrainImageView. - Pin
ndkVersionto the app's (getExtOrDefault("ndkVersion"), default 27.1.12297006). Built with a newer NDK (28), the.soneeds__cxa_init_primary_exception, which thelibc++_shared.sopackaged by react-android/fbjni (NDK 27) lacks →UnsatisfiedLinkError: dlopen failedat launch, Release builds only. - Coil stays on 3.4.0 (Kotlin 2.3.10 metadata); 3.5+ is compiled with Kotlin 2.4 and cannot be consumed by a Kotlin 2.2 app.
- Kingfisher: do not combine
.cacheMemoryOnlywith.cacheOriginalImage(fatal error inCacheCallbackCoordinator); the memory-only serializer is the safe equivalent. Do not add.backgroundDecode: ImageIO thumbnails are already decoded. nitrogen/generatedandsrc/generatedare committed so consumers need no codegen step. Regenerate withyarn nitrogenafter editing any*.nitro.ts, and commit the result.
Development
yarn # installs the library and the example workspace (nitro pinned to 0.36.5)
yarn nitrogen # regenerate Nitro specs (+ RN 0.87 patch + view config module)
yarn typecheck && yarn lint
yarn example prebuild --clean # generate native projects
yarn example start # Expo development server on port 8083
yarn example ios # or: yarn example androidexample/ (Expo SDK 58, RN 0.88.0) has a Demo screen exercising the whole surface: decode-to-size, require() + tint, tappable
overlay, onError, prefetch and a 200-row recycling list, and a Transcode screen (HEIC/WebP/PNG/https → JPEG at
a chosen max size, with before/after sizes).
End-to-end tests
Detox specs in example/e2e/ tap every button of the example on Release builds
(JS and assets bundled, no Metro); CI runs them on an iOS simulator and an Android emulator (e2e-ios, e2e-android).
brew tap wix/brew && brew install applesimutils # iOS permissions
yarn example prebuild --clean
yarn example e2e:build:ios && yarn example e2e:ios # iPhone 17 simulator (DETOX_IOS_DEVICE)
yarn example e2e:build:android && yarn example e2e:android # Medium_Phone AVD (DETOX_AVD; DETOX_ANDROID_ARCH=x86_64 on Intel)example/plugins/with-detox.js adds the Android wiring Detox needs (Detox AAR, DetoxTest, localhost cleartext for
Release); iOS needs none. The bundled HEIC sample is photo-heic.bin: Android Release builds reject .heic drawables,
so it ships as a raw resource instead.
Releasing
Merging to main never publishes. To release, open a release PR:
yarn release 0.2.0It bumps package.json on a release/0.2.0 branch and opens a PR whose description (drafted from the
commits since the last tag) becomes the release notes — edit it before merging. Merging the PR runs
publish.yml: checks, npm publish through trusted publishing
(no token to manage), tag v0.2.0 and the GitHub release with the tarball.
License
MIT
The example benchmark compares RN Image, FastImage, and obi-image with a fixed 10-second scroll sequence. It records mount-to-load-callback median/p95 times, failures, and incomplete rows, with up to 10 extra seconds for pending loads. Repeat cached URLs after a completed run to exercise caches; cache hits are not guaranteed. Recent comparisons remain available until the app reloads. These are end-to-end load times, not isolated decoder, memory, or frame-rate measurements.
