Elementos
Image
Image muestra una imagen de los assets de la app o de una URL. Las imágenes se descargan y se decodifican fuera del hilo principal, se reducen al tamaño con el que la vista las muestra y se almacenan en caché en memoria. Las imágenes animadas GIF, WebP y APNG se reproducen. Rust crea una con image(src), TSX con <Image> desde nsh.
import { Image, mount, signal, Text, View } from "nsh";
import { windowSize } from "nsh";
import { button, buttonText, focusRing, ui } from "./palette";
// The pictures are SVG `data:` URIs, so the demo needs no network. An app
// would import a file next to it (`import hills from "./hills.png"`) or pass
// a URL.
const svg = (body: string, width: number, height: number) =>
`data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`)}`;
const landscape = svg(
`<defs><linearGradient id="s" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7ea8ff"/><stop offset="1" stop-color="#ffd59e"/></linearGradient></defs>
<rect width="1200" height="800" fill="url(#s)"/>
<circle cx="880" cy="250" r="90" fill="#fff4c2"/>
<path d="M0 560 Q 250 380 520 520 T 1200 470 V800 H0Z" fill="#6aa86b"/>
<path d="M0 650 Q 330 520 700 640 T 1200 610 V800 H0Z" fill="#3f7d4c"/>
<text x="40" y="770" font-family="system-ui" font-size="40" fill="#ffffff">1200 × 800</text>`,
1200,
800,
);
const portrait = svg(
`<defs><linearGradient id="n" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1b1f3a"/><stop offset="1" stop-color="#b79cff"/></linearGradient></defs>
<rect width="600" height="900" fill="url(#n)"/>
<circle cx="300" cy="260" r="120" fill="#f4f1ff"/>
<circle cx="350" cy="230" r="110" fill="#2a2f55"/>
<path d="M0 700 L 180 520 L 330 660 L 450 560 L 600 700 V900 H0Z" fill="#141629"/>
<text x="30" y="870" font-family="system-ui" font-size="36" fill="#ffffff">600 × 900</text>`,
600,
900,
);
// Shown until the picture loads, and in its place when it fails.
const placeholder = svg(
`<rect width="120" height="80" fill="#8d9198" opacity="0.25"/><path d="M20 62 L48 34 L68 52 L80 42 L100 62Z" fill="#8d9198"/><circle cx="86" cy="24" r="8" fill="#8d9198"/>`,
120,
80,
);
// An icon drawn in one color: `tintColor` recolors it.
const star = svg(`<path d="M12 2l3 6.5 7 .8-5.2 4.8 1.5 7L12 17.6 5.7 21.1l1.5-7L2 9.3l7-.8z" fill="#000"/>`, 24, 24);
const sources = { landscape, portrait, missing: "asset:/missing.png" } as const;
type Source = keyof typeof sources;
const modes = ["cover", "contain", "fill", "none"] as const;
type Mode = (typeof modes)[number];
function Segments<T extends string>(props: { options: readonly T[]; value: T; onChange: (v: T) => void }) {
return (
<View style={{ ...ui().inset, flexDirection: "row", padding: 3, borderRadius: 10 }}>
<Each of={props.options}>
{(option) => {
const on = () => props.value === option();
return (
<View
onPress={() => props.onChange(option())}
style={{
flex: 1,
alignItems: "center",
paddingVertical: 6,
borderRadius: 8,
backgroundColor: on() ? ui().raised.backgroundColor : "transparent",
backgroundImage: on() ? ui().raised.backgroundImage : "none",
boxShadow: on() ? ui().raised.boxShadow : "none",
}}
hoverStyle={{ opacity: 0.88 }}
focusStyle={focusRing()}
activeStyle={{ opacity: 0.7 }}
>
<Text style={{ fontWeight: "600", color: on() ? ui().text : ui().muted }}>{option()}</Text>
</View>
);
}}
</Each>
</View>
);
}
function App() {
const source = signal<Source>("landscape");
const mode = signal<Mode>("cover");
const blur = signal(false);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
const wide = () => windowSize().width >= 700;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>image</Text>
<View style={{ flex: 1, flexDirection: wide() ? "row" : "column", gap: 16 }}>
<Image
src={sources[source()]}
placeholder={placeholder}
blurRadius={blur() ? 12 : 0}
fadeDuration={250}
onLoadStart={() => log("onLoadStart")}
onLoad={({ size }) => log(`onLoad ${Math.round(size.width)} × ${Math.round(size.height)}`)}
onError={(e) => log(`onError ${e.message}`)}
onLoadEnd={() => log("onLoadEnd")}
style={{ ...ui().inset, flex: 1, minHeight: 180, borderRadius: 14, objectFit: mode() }}
/>
<View style={{ gap: 12, width: wide() ? 320 : undefined }}>
<Text style={{ color: ui().muted }}>src</Text>
<Segments options={Object.keys(sources) as Source[]} value={source()} onChange={(s) => source.set(s)} />
<Text style={{ color: ui().muted }}>objectFit</Text>
<Segments options={modes} value={mode()} onChange={(m) => mode.set(m)} />
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View onPress={() => blur.set(!blur())} {...button("secondary", { on: blur })}>
<Text style={buttonText("secondary", { on: blur })}>blurRadius {blur() ? 12 : 0}</Text>
</View>
<Text style={{ color: ui().muted }}>tintColor</Text>
<Image src={star} style={{ width: 24, height: 24, tintColor: ui().accent }} />
<Image src={star} style={{ width: 24, height: 24, tintColor: ui().info }} />
</View>
<View style={{ ...ui().card, borderRadius: 12, padding: 12, gap: 4 }}>
<Text style={{ color: ui().muted, fontSize: 12 }}>Events, newest first</Text>
<Each of={events()}>
{(line) => <Text style={{ color: ui().text, fontFamily: "monospace", fontSize: 13 }}>{line()}</Text>}
</Each>
</View>
</View>
</View>
</View>
);
}
mount(App);use nsh_core::prelude::*;
// assets/logo.png in the app's crate.
const LOGO: ImageAsset = asset!("logo.png");
view()
.flex_row()
.gap_3()
.child(image(LOGO))
.child(
image("https://example.com/avatar.jpg")
.placeholder(LOGO)
.object_fit(ResizeMode::Cover)
.size(px(64.0))
.rounded_full(),
)import { Image, View } from "nsh";
import logo from "./assets/logo.png";
<View style={{ flexDirection: "row", gap: 12 }}>
<Image src={logo} />
<Image
src="https://example.com/avatar.jpg"
placeholder={logo}
style={{ width: 64, height: 64, borderRadius: 32 }}
/>
</View>;Un asset conoce su tamaño: sin ancho ni alto, la imagen lo toma de él, y con uno de los dos el otro se deduce de la relación de aspecto del asset. El tamaño de una URL se conoce una vez que carga, así que da a las imágenes remotas un ancho y un alto (o una relación de aspecto junto con uno de ellos).
#Fuentes
La fuente (src y placeholder; image(..) y .placeholder(..) en Rust) acepta:
| Valor | Ejemplo | Descripción |
|---|---|---|
| Un asset | Rust asset!("logo.png"), TSX import logo from "./logo.png" |
Un ImageAsset { url, size, scales? }, con tamaño en puntos: un archivo @2x cuenta la mitad de sus píxeles. Con logo@2x.png / logo@3x.png junto a logo.png, se carga la variante correspondiente a la densidad de píxeles de la pantalla (densidades de píxeles). Se pueden importar .png, .jpg, .jpeg, .gif, .webp y .heic, y también .svg, con el tamaño dado por su width y height o viewBox (imágenes SVG). |
| Una URL | "https://example.com/a.jpg" |
Se descarga y se almacena en caché. Las descargas de la misma URL se comparten entre vistas. |
| Una ruta de asset | "asset:/images/a.png" |
Un archivo de los assets de la app. |
Un URI data: |
"data:image/png;base64,iVBOR…" |
Bytes en el URI, en base64 o con codificación porcentual (data:image/svg+xml,%3Csvg…). |
Una URL blob: |
URL.createObjectURL(blob) |
Los bytes de un Blob o File (TSX). La imagen los conserva: revocar la URL deja una imagen que se muestra completa. |
Una imagen creada a partir de bytes que la app ya tiene se muestra sin hacer una petición. Un chat muestra una foto elegida desde su blob mientras se sube, y después cambia src a la URL de la foto:
// Bytes in a `data:` URI (an image the app made or received inline).
image(format!("data:image/png;base64,{encoded}")).size(px(64.0))import { Image, signal } from "nsh";
const preview = URL.createObjectURL(photo); // the picked `File`
const src = signal(preview);
upload(photo).then((url) => {
src.set(url);
URL.revokeObjectURL(preview);
});
<Image src={src()} style={{ width: 240, height: 180 }} />;Los bytes iguales son una sola imagen: la misma imagen como URI data: y como blob se decodifica una vez y comparte la caché.
Durante el desarrollo, los assets los sirve el servidor de desarrollo; nsh build y nsh package los copian en la app (un crate de Rust incluye su directorio assets/ con registry.assets(assets!())). Consulta imágenes y assets.
#Props
Además de las props comunes (con style tipado como ImageStyle):
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
src |
image(src) |
string | ImageAsset |
La imagen. | |
placeholder |
.placeholder(src) |
string | ImageAsset |
Se muestra hasta que src se haya cargado; lo mejor es un asset local. |
|
blurRadius |
.blur_radius(px(..)) |
number |
Desenfoca la imagen con este radio en puntos (se aplica una sola vez durante la decodificación, fuera del hilo principal). | |
fadeDuration |
.fade_duration(ms) |
number |
iOS 0, Android 300 |
Cuánto dura, en milisegundos, el fundido de entrada de una imagen cargada. |
tintColor (estilo) |
.tint_color(..) |
ColorValue |
Cambia el color de una imagen de plantilla (un icono dibujado en un solo color). | |
objectFit (estilo) |
.object_fit(ResizeMode::Contain) |
"cover" | "contain" | "fill" | "none" | "scale-down" |
"cover" |
Cómo se ajusta la imagen a su caja, como el object-fit de CSS (clases object-*): "cover" la llena y recorta, "contain" la ajusta dentro, "fill" la estira, "none" la muestra a su tamaño natural, "scale-down" lo mismo, reducida para caber donde es más grande. |
objectPosition (estilo) |
.object_position(..), .object_top(), … |
string |
"center" |
Dónde se sitúa la imagen en su caja una vez que objectFit la ha dimensionado, como el object-position de CSS ("top", "25% 75%", "right 10px bottom 20%"); una imagen que cubre se recorta alrededor de ese punto. |
onLoadStart |
.on_load_start(..) |
() => void |
Comenzó la carga. | |
onProgress |
.on_progress(..) |
(e: { loaded: number; total: number }) => void |
Progreso de la descarga en bytes (total es 0 cuando se desconoce), como máximo cada 100 ms. |
|
onLoad |
.on_load(..) |
(e: { size: PixelSize }) => void |
Cargada; el tamaño de la imagen original en píxeles (no el de la decodificada y reducida; el tamaño de un SVG en puntos, en la web tal como lo mide el navegador); para una imagen con variantes, el de la variante cargada. | |
onError |
.on_error(..) |
(e: { message: string; code?: string }) => void |
La carga falló; code es "ERR_ACCESS_DENIED" para una URL que las capabilities de la app no permiten. |
|
onLoadEnd |
.on_load_end(..) |
() => void |
Después de onLoad o onError. |
ImageStyle añade tintColor a los estilos de la vista, que cambia el color de una imagen de plantilla (un icono dibujado en un solo color), y objectPosition, que sitúa la imagen en su caja (Posición de la imagen).
image(ICON).size(px(24.0)).tint_color(rgb(0x0969da))<Image src={icon} style={{ width: 24, height: 24, tintColor: "#0969da" }} />#Eventos
| Evento | Rust | Payload | Cuándo |
|---|---|---|---|
onLoadStart |
.on_load_start |
ninguno | Comenzó la carga. |
onProgress |
.on_progress |
{ loaded, total } (ImageProgress) |
Llegaron bytes (como máximo cada 100 ms). |
onLoad |
.on_load |
{ size } (Size<DevicePixels>) |
La imagen se muestra; su tamaño original en píxeles (el de un SVG en puntos, en la web tal como lo mide el navegador). |
onError |
.on_error |
{ message, code? } (&str, el mensaje) |
La carga o la decodificación falló, o el host queda fuera de las capabilities de la app (code "ERR_ACCESS_DENIED"). |
onLoadEnd |
.on_load_end |
ninguno | Después de onLoad o onError. |
let state = signal(SharedString::from("loading"));
image(url)
.w_full()
.aspect_ratio(16.0 / 9.0)
.on_load(move |size: &Size<DevicePixels>| state.set(format!("{}×{}", size.width.0, size.height.0).into()))
.on_error(move |message: &str| state.set(message.to_string().into()))const state = signal("loading");
<Image
src={url}
style={{ width: "100%", aspectRatio: 16 / 9 }}
onLoad={({ size }) => state.set(`${size.width}×${size.height} px`)}
onError={(e) => state.set(e.message)}
/>;Una imagen con variantes de densidad de píxeles carga uno de sus archivos, y onLoad informa de los píxeles de ese archivo: 192×192 para logo@3x.png en una pantalla 3×, para un logo.png de 64×64 puntos. Cuando la densidad de píxeles cambia y la imagen carga otra variante, también informa de esa carga: onLoadStart, luego onLoad con el tamaño del nuevo archivo (o onError), y después onLoadEnd.
Para conocer el tamaño de una imagen o cargarla antes de mostrarla, TSX llama a Image.getSize(src) y Image.prefetch(src) (el mismo Image que renderiza el elemento los incluye; consulta la API de Image), y Rust usa image_size(src) y prefetch_image(src), con await en una tarea. Dada una imagen importada con variantes, Image.getSize y Image.prefetch cargan la variante que muestra esta pantalla, y getSize devuelve el tamaño del archivo importado (64×64 para logo.png).
#Métodos de ref
Ninguno propio.
#Notas de plataforma
- La clave de la caché incluye el tamaño de decodificación y el radio de desenfoque: la misma URL mostrada en dos tamaños se decodifica dos veces, pero se descarga una sola.
- iOS reproduce las imágenes animadas como una animación de fotogramas clave (hasta 600 fotogramas); una imagen animada con tinte muestra su primer fotograma.
- Android reproduce las imágenes animadas desde la API 28 (
AnimatedImageDrawable), sin caché y sin desenfoque. - En Android los bordes se dibujan por encima de la imagen, igual que en iOS.