Elementos
Image
Image mostra uma imagem dos assets do app ou de uma URL. As imagens são baixadas e decodificadas fora da thread principal, reduzidas ao tamanho em que a view as exibe e armazenadas em cache na memória. Imagens animadas GIF, WebP e APNG são reproduzidas. Em Rust, cria-se uma com image(src); em TSX, com <Image> a partir de 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>;Um asset conhece seu próprio tamanho: sem largura nem altura, a imagem adota esse tamanho, e, com apenas uma das duas, a outra acompanha a proporção do asset. O tamanho de uma URL só é conhecido depois que ela carrega, então defina largura e altura para imagens remotas (ou uma proporção junto com uma das duas).
#Fontes
A fonte (src e placeholder; image(..) e .placeholder(..) em Rust) aceita:
| Valor | Exemplo | Descrição |
|---|---|---|
| Um asset | Rust asset!("logo.png"), TSX import logo from "./logo.png" |
Um ImageAsset { url, size, scales? }, dimensionado em pontos: um arquivo @2x conta metade de seus pixels. Com logo@2x.png / logo@3x.png ao lado de logo.png, a variante correspondente à densidade de pixels da tela é carregada (densidades de pixels). É possível importar .png, .jpg, .jpeg, .gif, .webp e .heic, além de .svg, dimensionado por seus width e height ou viewBox (imagens SVG). |
| Uma URL | "https://example.com/a.jpg" |
Baixada e armazenada em cache. Downloads da mesma URL são compartilhados entre views. |
| Um caminho de asset | "asset:/images/a.png" |
Um arquivo dos assets do app. |
Um URI data: |
"data:image/png;base64,iVBOR…" |
Bytes no próprio URI, em base64 ou com codificação percentual (data:image/svg+xml,%3Csvg…). |
Uma URL blob: |
URL.createObjectURL(blob) |
Os bytes de um Blob ou File (TSX). A imagem os mantém: revogar a URL deixa uma imagem que continua sendo exibida por inteiro. |
Uma imagem criada a partir de bytes que o app já possui é exibida sem requisição. Um chat mostra uma foto escolhida a partir do blob enquanto ela é enviada e, depois, troca src para a URL da 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 }} />;Bytes iguais formam uma única imagem: a mesma imagem como URI data: e como blob é decodificada uma só vez e compartilha o cache.
Durante o desenvolvimento, os assets são servidos pelo servidor de desenvolvimento; nsh build e nsh package os copiam para dentro do app (um crate Rust distribui seu diretório assets/ com registry.assets(assets!())). Veja imagens e assets.
#Props
Além das props comuns (com style tipado como ImageStyle):
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
src |
image(src) |
string | ImageAsset |
A imagem. | |
placeholder |
.placeholder(src) |
string | ImageAsset |
Exibido até que src seja carregado; o ideal é um asset local. |
|
blurRadius |
.blur_radius(px(..)) |
number |
Desfoca a imagem com este raio em pontos (aplicado uma única vez durante a decodificação, fora da thread principal). | |
fadeDuration |
.fade_duration(ms) |
number |
iOS 0, Android 300 |
Por quanto tempo uma imagem carregada aparece em fade-in, em milissegundos. |
tintColor (estilo) |
.tint_color(..) |
ColorValue |
Recolore uma imagem template (um ícone desenhado em uma só cor). | |
objectFit (estilo) |
.object_fit(ResizeMode::Contain) |
"cover" | "contain" | "fill" | "none" | "scale-down" |
"cover" |
Como a imagem se ajusta à sua caixa, como o object-fit do CSS (classes object-*): "cover" a preenche e corta, "contain" a encaixa dentro dela, "fill" a estica, "none" a exibe em seu tamanho natural, "scale-down" o mesmo, reduzida para caber quando é maior. |
objectPosition (estilo) |
.object_position(..), .object_top(), … |
string |
"center" |
Onde a imagem fica em sua caixa depois que objectFit a dimensionou, como o object-position do CSS ("top", "25% 75%", "right 10px bottom 20%"); uma imagem que cobre a caixa é cortada ao redor dessa posição. |
onLoadStart |
.on_load_start(..) |
() => void |
O carregamento começou. | |
onProgress |
.on_progress(..) |
(e: { loaded: number; total: number }) => void |
Progresso do download em bytes (total é 0 quando desconhecido), no máximo a cada 100 ms. |
|
onLoad |
.on_load(..) |
(e: { size: PixelSize }) => void |
Carregada; o tamanho da imagem de origem em pixels (não o da imagem decodificada e reduzida; o tamanho de um SVG em pontos, na web conforme medido pelo navegador); para uma imagem com variantes, o da variante carregada. | |
onError |
.on_error(..) |
(e: { message: string; code?: string }) => void |
O carregamento falhou; code é "ERR_ACCESS_DENIED" para uma URL que as capabilities do app não permitem. |
|
onLoadEnd |
.on_load_end(..) |
() => void |
Após onLoad ou onError. |
ImageStyle adiciona tintColor aos estilos da view, que recolore uma imagem template (um ícone desenhado em uma só cor), e objectPosition, que posiciona a imagem em sua caixa (posição da imagem).
image(ICON).size(px(24.0)).tint_color(rgb(0x0969da))<Image src={icon} style={{ width: 24, height: 24, tintColor: "#0969da" }} />#Eventos
| Evento | Rust | Payload | Quando |
|---|---|---|---|
onLoadStart |
.on_load_start |
nenhum | O carregamento começou. |
onProgress |
.on_progress |
{ loaded, total } (ImageProgress) |
Chegaram bytes (no máximo a cada 100 ms). |
onLoad |
.on_load |
{ size } (Size<DevicePixels>) |
A imagem é exibida; o tamanho da origem em pixels (o de um SVG em pontos, na web conforme medido pelo navegador). |
onError |
.on_error |
{ message, code? } (&str, a mensagem) |
O carregamento ou a decodificação falhou, ou o host está fora das capabilities do app (code "ERR_ACCESS_DENIED"). |
onLoadEnd |
.on_load_end |
nenhum | Apó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)}
/>;Uma imagem com variantes de densidade de pixels carrega um de seus arquivos, e onLoad informa os pixels desse arquivo: 192×192 para logo@3x.png em uma tela 3×, para um logo.png de 64×64 pontos. Quando a densidade de pixels muda e a imagem carrega outra variante, ela também informa esse carregamento: onLoadStart, depois onLoad com o tamanho do novo arquivo (ou onError), depois onLoadEnd.
Para saber o tamanho de uma imagem ou carregá-la antes de exibi-la, TSX chama Image.getSize(src) e Image.prefetch(src) (o mesmo Image que renderiza o elemento os expõe; veja a API Image), e Rust usa image_size(src) e prefetch_image(src), com await em uma task. Dada uma imagem importada com variantes, Image.getSize e Image.prefetch carregam a variante que esta tela exibe, e getSize retorna o tamanho do arquivo importado (64×64 para logo.png).
#Métodos de ref
Nenhum próprio.
#Notas de plataforma
- A chave de cache inclui o tamanho de decodificação e o raio de desfoque: a mesma URL exibida em dois tamanhos é decodificada duas vezes, mas baixada uma só vez.
- O iOS reproduz imagens animadas como uma animação de quadros-chave (até 600 quadros); uma imagem animada com tint mostra apenas o primeiro quadro.
- O Android reproduz imagens animadas a partir da API 28 (
AnimatedImageDrawable), sem cache e sem desfoque. - No Android, as bordas são desenhadas acima da imagem, como no iOS.