Éléments
Image
Image affiche une image provenant des ressources de l’app ou d’une URL. Les images sont téléchargées et décodées hors du thread principal, sous-échantillonnées à la taille à laquelle la vue les affiche, puis mises en cache en mémoire. Les images animées GIF, WebP et APNG sont lues. En Rust, on en crée une avec image(src), en TSX avec <Image> depuis 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>;Une ressource connaît sa taille : sans largeur ni hauteur, l’image prend celle de la ressource, et avec l’une des deux, l’autre s’en déduit grâce au rapport d’aspect de la ressource. La taille d’une URL n’est connue qu’une fois l’image chargée ; donnez donc aux images distantes une largeur et une hauteur (ou un rapport d’aspect avec l’une des deux).
#Sources
La source (src et placeholder ; image(..) et .placeholder(..) en Rust) accepte :
| Valeur | Exemple | Description |
|---|---|---|
| Une ressource | Rust asset!("logo.png"), TSX import logo from "./logo.png" | Un ImageAsset { url, size, scales? }, dimensionné en points : un fichier @2x compte la moitié de ses pixels. Avec logo@2x.png / logo@3x.png à côté de logo.png, la variante correspondant au ratio de pixels de l’écran est chargée (densités de pixels). .png, .jpg, .jpeg, .gif, .webp et .heic peuvent être importés, ainsi que .svg, dimensionné par ses width et height ou viewBox (images SVG). |
| Une URL | "https://example.com/a.jpg" | Téléchargée et mise en cache. Les téléchargements d’une même URL sont partagés entre les vues. |
| Un chemin de ressource | "asset:/images/a.png" | Un fichier des ressources de l’app. |
Une URI data: | "data:image/png;base64,iVBOR…" | Des octets dans l’URI, en base64 ou encodés en pourcentage (data:image/svg+xml,%3Csvg…). |
Une URL blob: | URL.createObjectURL(blob) | Les octets d’un Blob ou d’un File (TSX). L’image les conserve : révoquer l’URL laisse une image qui s’affiche en entier. |
Une image issue d’octets que l’app possède déjà s’affiche sans requête. Un chat affiche une photo choisie depuis son blob pendant son envoi, puis passe src à l’URL de la photo :
// 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 }} />;Des octets identiques forment une seule image : la même image sous forme d’URI data: et de blob n’est décodée qu’une fois et partage le cache.
Pendant le développement, les ressources sont servies par le serveur de développement ; nsh build et nsh package les copient dans l’app (un crate Rust embarque son répertoire assets/ avec registry.assets(assets!())). Voir images et ressources.
#Props
En plus des props communes (avec style typé ImageStyle) :
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
src | image(src) | string | ImageAsset | L’image. | |
placeholder | .placeholder(src) | string | ImageAsset | Affiché tant que src n’est pas chargé ; idéalement une ressource locale. | |
blurRadius | .blur_radius(px(..)) | number | Floute l’image selon ce rayon en points (appliqué une seule fois pendant le décodage, hors du thread principal). | |
fadeDuration | .fade_duration(ms) | number | iOS 0, Android 300 | Durée du fondu d’apparition d’une image chargée, en millisecondes. |
tintColor (style) | .tint_color(..) | ColorValue | Recolore une image modèle (une icône dessinée d’une seule couleur). | |
objectFit (style) | .object_fit(ResizeMode::Contain) | "cover" | "contain" | "fill" | "none" | "scale-down" | "cover" | Comment l’image s’ajuste à son cadre, comme object-fit en CSS (classes object-*) : "cover" le remplit et rogne, "contain" l’inscrit à l’intérieur, "fill" l’étire, "none" l’affiche à sa taille naturelle, "scale-down" pareil, réduite pour tenir là où elle est plus grande. |
objectPosition (style) | .object_position(..), .object_top(), … | string | "center" | Où l’image se place dans son cadre une fois dimensionnée par objectFit, comme object-position en CSS ("top", "25% 75%", "right 10px bottom 20%") ; une image qui couvre le cadre est rognée autour de ce point. |
onLoadStart | .on_load_start(..) | () => void | Le chargement a commencé. | |
onProgress | .on_progress(..) | (e: { loaded: number; total: number }) => void | Progression du téléchargement en octets (total vaut 0 si inconnu), au plus toutes les 100 ms. | |
onLoad | .on_load(..) | (e: { size: PixelSize }) => void | Chargée ; la taille de l’image source en pixels (pas celle de l’image décodée et sous-échantillonnée ; pour un SVG, sa taille en points, sur le web telle que le navigateur la mesure) ; pour une image avec variantes, celle de la variante chargée. | |
onError | .on_error(..) | (e: { message: string; code?: string }) => void | Échec du chargement ; code vaut "ERR_ACCESS_DENIED" pour une URL que les capabilities de l’app n’autorisent pas. | |
onLoadEnd | .on_load_end(..) | () => void | Après onLoad ou onError. |
ImageStyle ajoute tintColor aux styles de vue, qui recolore une image modèle (une icône dessinée d’une seule couleur), et objectPosition, qui place l’image dans son cadre (Position de l’image).
image(ICON).size(px(24.0)).tint_color(rgb(0x0969da))<Image src={icon} style={{ width: 24, height: 24, tintColor: "#0969da" }} />#Événements
| Événement | Rust | Charge utile | Quand |
|---|---|---|---|
onLoadStart | .on_load_start | aucune | Le chargement a commencé. |
onProgress | .on_progress | { loaded, total } (ImageProgress) | Des octets sont arrivés (au plus toutes les 100 ms). |
onLoad | .on_load | { size } (Size<DevicePixels>) | L’image est affichée ; sa taille source en pixels (pour un SVG, en points, sur le web telle que le navigateur la mesure). |
onError | .on_error | { message, code? } (&str, le message) | Le chargement ou le décodage a échoué, ou l’hôte est hors des capabilities de l’app (code "ERR_ACCESS_DENIED"). |
onLoadEnd | .on_load_end | aucune | Après onLoad ou 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)}
/>;Une image avec des variantes de densité de pixels charge l’un de ses fichiers, et onLoad indique les pixels de ce fichier : 192×192 pour logo@3x.png sur un écran 3×, pour un logo.png de 64×64 points. Lorsque le ratio de pixels change et que l’image charge une autre variante, elle signale aussi ce chargement : onLoadStart, puis onLoad avec la taille du nouveau fichier (ou onError), puis onLoadEnd.
Pour connaître la taille d’une image ou la charger avant de l’afficher, TSX appelle Image.getSize(src) et Image.prefetch(src) (le même Image qui rend l’élément les porte ; voir l’API Image), et Rust image_size(src) et prefetch_image(src), attendus dans une tâche. Avec une image importée possédant des variantes, Image.getSize et Image.prefetch chargent la variante affichée par cet écran, et getSize renvoie la taille du fichier importé (64×64 pour logo.png).
#Méthodes de ref
Aucune qui lui soit propre.
#Notes par plateforme
- La clé de cache inclut la taille de décodage et le rayon de flou : la même URL affichée à deux tailles est décodée deux fois mais téléchargée une seule fois.
- iOS lit les images animées comme une animation par images clés (jusqu’à 600 images) ; une image animée teintée affiche sa première image.
- Android lit les images animées à partir de l’API 28 (
AnimatedImageDrawable), sans cache et sans flou. - Sur Android, les bordures sont dessinées au-dessus de l’image, comme sur iOS.