react-native-true-image
v2.2.1
Published
A single-layer, cache-key-stable image component for React Native
Maintainers
Readme
react-native-true-image
A single-layer image component for React Native with a cache-key contract
between sized prefetch requests and the view, so matching images mount as
synchronous memory hits. Oversized bitmaps are downsampled during decoding. Built as a Fabric component and TurboModule on
SDWebImage (iOS) and Glide (Android).
One native view draws one image on one layer (iOS) or one canvas (Android). No subviews, no placeholder views.
Installation
yarn add react-native-true-imageRequires React Native 0.76 or later with the new architecture enabled.
Expo apps need a development build; the package works with expo prebuild
without a config plugin.
Usage
import { Image } from 'react-native-true-image';
<Image
source="https://cdn.example.com/cover.jpg"
recyclingKey={item.id}
transition={300}
style={{ width: 120, height: 120, borderRadius: 8 }}
onDisplayEnd={() => setFallbackVisible(false)}
/>;
await Image.prefetch(covers.map((c) => c.url));Sources
| Input | What native receives | Path |
| --- | --- | --- |
| Remote URL | the string as-is | SDWebImage / Glide through OkHttp |
| { uri, headers, cacheKey } | the URI plus a header list and cache key | same, with the headers on the request. See Variants of one picture |
| require() asset | Image.resolveAssetSource().uri | file loader / drawable |
| Scheme-less name, e.g. "ic_play" | the bare name | iOS asset catalog / Android drawable |
Scheme-less names are drawn in the same frame and never fade, which makes
them suitable for icons. tintColor applies to them only.
Headers are sent with the request and are not part of the cache key: the
same URL is one cached image whatever headers fetched it, so a prefetch
with headers and a view without them still share one entry. Pass the same
{ uri, headers } object to both when the server needs them.
Borders and rounded corners
The view takes every borderRadius, borderWidth, borderColor and
borderStyle prop a View takes, on both platforms, and clips the image
to the border box. There is no need for a clipping wrapper.
<Image source={url} style={{ width: 96, height: 96, borderRadius: 12 }} />Clipping relies on the default overflow: 'hidden'; overriding it to
visible on iOS draws the image square.
Props
| Prop | Default | Notes |
| --- | --- | --- |
| source | | string, { uri, headers, cacheKey }, require() id, or null to clear |
| resizeMode | 'cover' | cover, contain, stretch, center |
| downsampleThreshold | 2 | Linear oversize factor before decode-time downsampling. 1 always downsamples oversized images; 0 disables it |
| transition | 300 ms for URLs, 0 for assets | Fade duration in milliseconds |
| blurRadius | 0 | In source-image pixels, so the same value looks the same on both platforms |
| blurPixelsPerRadius | 2 | Pixels the blur radius spans after the pre-blur shrink; 0 blurs at full size. See Blur cost |
| tintColor | | Native resources only |
| recyclingKey | | Clears the view synchronously when it changes, before the next source loads |
| placeholder | | Shown into an empty view until source loads. Same shapes as source. See Placeholder |
| placeholderTransition | same as transition | Crossfade from the placeholder to the image, in milliseconds; 0 cuts |
| placeholderPolicy | 'cache-only' | cache-only shows a remote placeholder only if already cached; network fetches it |
Events
onLoad({ width, height, source }): the image decoded. Sizes are pixels. With acacheKeythe image may be a reused variant, so the size can differ from the URL's;sourceis still the URL the view asked for.onError({ error, source }): the load failed. Not fired for a load cancelled by a newer source.onDisplay(): the image was handed to the view, at the start of a fade or on draw.onDisplayEnd(): the image is fully drawn, after the fade or together withonDisplaywhen there is none.
A fade softens an image arriving late. A memory-cached image arriving into an
empty view draws at once regardless of transition. Replacing a displayed
image always crossfades. An image whose fade is interrupted never reports
onDisplayEnd; the newer image reports in its turn.
Prefetch
Image.prefetch(source | source[], options?) takes URL strings or
{ uri, headers, displaySize?, resizeMode?, downsampleThreshold? } objects
and resolves true only if every image loaded. Options apply to the whole
batch; per-source options override them.
await Image.prefetch(covers.map((cover) => cover.url), {
displaySize: { width: 100, height: 100 },
resizeMode: 'cover',
downsampleThreshold: 2,
});
<Image source={cover.url} style={{ width: 100, height: 100 }} />displaySize uses React Native layout units, like style.width and
style.height. Native code converts to physical pixels using screen density.
A view uses its measured size automatically and waits for nonzero layout
before starting a sized load. Matching physical dimensions, resize mode and
threshold share a decoded memory-cache entry. Estimates also work: a view
reuses an existing variant when it has enough detail and is no more than the
threshold above the required resolution. A full-resolution source that is
smaller than the view can also be reused. A reduced variant that would need
upscaling, an overly large variant, or changed decode settings require a new
decode. Headers remain outside the key.
Original downloaded data stays shared by URL on disk. Different sizes can
therefore decode from disk without downloading the image again.
Rendering defaults to downsampleThreshold={2}. When an image has more than
twice the required resolution on each axis, it decodes near its displayed
pixel size. Exactly 2× keeps the original; the threshold is linear, so 2×
on both axes means four times the pixels. cover accounts for the portion
outside the view, contain for the fitted image, and stretch conservatively
retains enough detail on both axes. center and native vectors do not
participate in downsampling. Images are never upscaled during decoding.
The decoder may round the resulting dimensions, and onLoad reports the
actual decoded pixel dimensions.
Set downsampleThreshold={0} to retain the original-resolution behavior.
Thresholds must be finite and either 0 or at least 1; display dimensions
must be finite and positive. Prefetching without displaySize still loads
the original resolution, but does not guarantee a synchronous memory hit
for a sized view. To keep the previous URL-only prefetch contract, opt out
of downsampling on the view as well.
On Android the prefetch loop runs on a background thread: each load is
started with submit() and awaited there, and the main looper sees a single
post per batch. Prefetches usually race the mount work they feed, so they
stay out of its queue.
Colours
Image.getColors(source, options?) picks colours from an image, for example
to tint a player with its album artwork. It accepts the same shapes as
source; options.headers are sent with the request, and a
{ uri, headers } source's own headers win over them.
const colors = await Image.getColors(track.artworkUrl);
if (colors) {
setTint(colors.darkVibrant ?? colors.darkMuted ?? colors.dominant);
}It resolves an ImageColors object, or null if the image could not be
loaded or decoded, or has no opaque pixels:
| Field | Notes |
| --- | --- |
| dominant | The most common colour after the filter below, as in Palette: a mostly black cover reports its most common other colour. The mean colour when the filter leaves nothing. Never null |
| vibrant, lightVibrant, darkVibrant | Saturated colours at mid, light and dark lightness |
| muted, lightMuted, darkMuted | Desaturated colours at mid, light and dark lightness |
Every colour is a #rrggbb string. The six named swatches are null when no
colour in the image fits them, and a colour fills one of them at most.
The image loads through the same request as a view or prefetch of that URL, so a cached image is not downloaded again. Native code decodes it near 48 × 48 pixels and stretches it to that square; the palette is computed in JS from those pixels, so both platforms pick colours the same way. The algorithm follows Android's Palette: transparent pixels are skipped, near black, near white and skin-like reds are filtered out, the rest is reduced to 16 colours by median cut, and each colour is scored against the six targets by saturation, lightness and how much of the image it covers. Swatch colours are the mean of the pixels they cover, so a flat colour comes back exactly. Scaling filters differ slightly between the platforms, so the same image can give colours a level or two apart.
Variants of one picture: cacheKey
Some servers hand out one picture at several URLs, one per size, such as an
image proxy's …/cover/42?size=80, ?size=256 and ?size=512. Each URL is
cached on its own, so without help a 512 px album header loads from scratch
although the 256 px list row it came from is in memory. Give such URLs a
shared cacheKey to say they are the same picture:
const artwork = (size: number) => ({
uri: `${server}/imageproxy/${album.id}?size=${size}`,
cacheKey: `album:${album.id}`,
});
<Image source={artwork(256)} style={{ width: 80, height: 80 }} />;
<Image source={artwork(1024)} style={{ width: 320, height: 320 }} />;A view whose source has a cacheKey:
- Reuses a variant instead of fetching. If another URL with the same
key is cached at a resolution that fills the view without upscaling, it
is shown and the requested URL is not fetched. "Fills" means at least the
view's pixel size on both axes for
coverandstretch, and on the axis the image fits by forcontain. It decodes under the usualdownsampleThresholdpolicy. A variant whose decode for this view is already in memory draws at once, exactly like an exact memory hit; otherwise it decodes from the disk cache without touching the network. - Stands a smaller variant in while loading. Otherwise the requested URL
loads as usual, and the largest cached variant that is too small is shown
as a placeholder in the meantime: at once when it is in memory, after a
disk read otherwise. The image then crossfades in over
placeholderTransition, exactly as from aplaceholder. - Changes nothing else. The exact URL is still preferred when it is
cached. The order is: the URL in memory, a sufficient variant in memory,
the URL on disk, a sufficient variant on disk, the network. Sources
without a
cacheKey,resizeMode="center"and native resources behave as before. A view needs its layout to judge sufficiency; before it has one, a variant can only stand in.
The variant counts as the image: it reports onLoad, onDisplay and
onDisplayEnd like any other, with the variant's pixel size in onLoad.
A smaller stand-in is a placeholder and reports nothing. recyclingKey
clears and interrupted fades work as for any image. Headers stay out of the
key: a variant is requested with the headers of the source asking for it.
Image.prefetch with a cacheKey registers the URL as a variant, and
Image.getColors samples a cached variant instead of fetching, preferring
the smallest that covers its 48 px sample.
Each platform keeps an index from cache key to the URLs seen with it and each URL's full pixel size, recorded whenever a load or prefetch with a key succeeds. It holds no pixels. It is stored as a small JSON file in the app's caches directory so it survives restarts, written off the main thread a second after a change, and bounded to 2000 keys (least recently used are dropped) and 8 URLs per key. An unreadable file is discarded. The index never claims a cache hit on its own: each variant is looked up cache-only, and one the image cache has evicted is forgotten and the next choice is tried, ending with the network for the requested URL.
Placeholder
A placeholder stands in while source loads: a low-resolution copy that a
list row already cached, or a bundled asset. It shows only into an empty
view, which is on mount, after a recyclingKey change, or after source
was cleared. A displayed image is never swapped for a placeholder while its
replacement loads. The placeholder draws the moment it is available, never
fades in, and reports no events, so onLoad and friends always describe
source. When the image arrives it crossfades over placeholderTransition
or cuts at 0. An image that is a memory hit shows at once and the
placeholder is never loaded.
With a cacheKey on source, a cached smaller variant of the same picture
is preferred over a remote cache-only placeholder, which is still tried if
no variant is cached. A local placeholder, or a remote one with the
network policy, is the app's explicit choice and is used instead.
placeholderPolicy decides where a remote placeholder may come from.
cache-only reads the memory and disk caches and gives up silently
otherwise, so it never costs a request. network fetches it like any other
image, through the same request as a view or prefetch of that URL, so it
lands in the shared cache. Local placeholders always show. blurRadius and
resizeMode apply to the placeholder as well.
Blur cost
A Gaussian blur erases every detail finer than its radius, so blurring a
copy shrunk until the radius spans a couple of pixels looks the same as
blurring the original. blurPixelsPerRadius sets how many pixels that is.
With the default of 2, a radius of 100 shrinks the image fifty times per
axis and blurs it with a radius of 2. Raise it to keep more detail in the
blurred image, or set it to 0 to blur at full size.
Both platforms blur once, off the main thread, and then draw the blurred bitmap like any other. A crossfade between blurred images is two bitmap draws per frame, with no per-frame filter: a blur is linear, so fading between two blurred images is pixel-identical to blurring the fade. A blurred image is never shown sharp first; it appears once the blur is done.
On iOS the shrink runs inside the SDWebImage transform and the result is cached under a key that includes the shrink factor. The sharp original stays cached and is the transform input, so a blurred view still reuses a prefetch. On Android the view blurs the drawable Glide hands it, so the request and its memory cache entry stay identical to the prefetch, and a small cache keyed on source, radius and factor makes the blur a synchronous hit for recycled rows. The two blurs use the same three-pass box approximation of a Gaussian, so one radius looks the same on both.
Android: parallel decoding of disk-cached images
Glide decodes disk-cached images on a single thread by default. Fresh
downloads decode on a pool of up to four threads, but a DecodeJob for an
image already on disk runs on the disk cache executor, which has one thread.
A cold scroll through a list whose images are on disk decodes them one at a
time.
Measure before opting in. On one launch profile with twenty prefetched covers, decode was under a third of the batch's wall time and adding threads changed time-to-paint by under one percent; the rest was main-thread scheduling, which the background prefetch loop now avoids. The helper helps a cold scroll of disk-cached images that were not prefetched, and little else.
Executors can only be configured through the app's AppGlideModule, so this
package cannot change it on its own. To opt in, add the Glide annotation
processor to your app and call the helper from applyOptions:
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.module.AppGlideModule
import com.trueimage.TrueImageGlideOptions
@GlideModule
class AppGlide : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
TrueImageGlideOptions.apply(builder)
}
}apply sizes the disk cache executor like Glide's source executor,
min(4, CPU cores). Pass a threadCount to choose differently.
Development
yarn # install
yarn test # JS contract tests
swift test # iOS geometry and policy tests, runs on the host
yarn example android
yarn example iosAndroid unit tests (pure policy plus Robolectric tests of the view and the prefetch contract against Glide) run with the example's Gradle wrapper:
yarn example test:androidiOS view and prefetch tests run as an XCTest bundle hosted by the example
app, against SDWebImage with a fake network, on the first available iPhone
simulator (the pods must be installed in example/ios):
yarn example test:iosCI runs all of these, plus the example builds, on every push and pull request.
A Nix flake provides the Android toolchain: nix develop for the SDK and
JDK, nix develop .#emulator to include an emulator image.
License
MIT
