Элементы
Image
Image показывает изображение из ресурсов приложения или по URL. Изображения загружаются и декодируются вне главного потока, уменьшаются до размера, в котором их показывает представление, и кэшируются в памяти. Анимированные GIF, WebP и APNG воспроизводятся. В Rust его создаёт image(src), в TSX — <Image> из 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>;Размер ресурса известен заранее: если не заданы ни ширина, ни высота, изображение берёт его, а если задана одна из сторон, вторая вычисляется по соотношению сторон ресурса. Размер URL становится известен только после загрузки, поэтому удалённым изображениям задавайте и ширину, и высоту (или соотношение сторон и одну из них).
#Источники
Источник (src и placeholder; image(..) и .placeholder(..) в Rust) принимает:
| Значение | Пример | Описание |
|---|---|---|
| Ресурс | Rust asset!("logo.png"), TSX import logo from "./logo.png" | ImageAsset { url, size, scales? } с размером в пунктах: файл @2x считается как половина его пикселей. Если logo@2x.png / logo@3x.png лежат рядом с logo.png, загружается вариант для плотности пикселей экрана (плотности пикселей). Можно импортировать .png, .jpg, .jpeg, .gif, .webp и .heic, а также .svg с размером по width и height или viewBox (SVG-изображения). |
| URL | "https://example.com/a.jpg" | Загружается и кэшируется. Загрузки одного и того же URL разделяются между представлениями. |
| Путь к ресурсу | "asset:/images/a.png" | Файл из ресурсов приложения. |
URI data: | "data:image/png;base64,iVBOR…" | Байты в самом URI в base64 или с процентным кодированием (data:image/svg+xml,%3Csvg…). |
URL blob: | URL.createObjectURL(blob) | Байты Blob или File (TSX). Изображение сохраняет их: после отзыва URL оно по-прежнему показывается целиком. |
Изображение из байтов, которые уже есть у приложения, показывается без запроса. Чат показывает выбранное фото из его blob, пока оно загружается, а затем переключает src на URL фото:
// 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 }} />;Одинаковые байты — это одно изображение: одна и та же картинка в виде URI data: и в виде blob декодируется один раз и использует общий кэш.
Во время разработки ресурсы раздаёт dev-сервер; nsh build и nsh package копируют их в приложение (крейт Rust поставляет свой каталог assets/ вместе с registry.assets(assets!())). См. изображения и ресурсы.
#Свойства
Помимо общих свойств (где style имеет тип ImageStyle):
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
src | image(src) | string | ImageAsset | Изображение. | |
placeholder | .placeholder(src) | string | ImageAsset | Показывается, пока не загрузится src; лучше всего подходит локальный ресурс. | |
blurRadius | .blur_radius(px(..)) | number | Размывает изображение с этим радиусом в пунктах (применяется один раз при декодировании, вне главного потока). | |
fadeDuration | .fade_duration(ms) | number | iOS 0, Android 300 | Сколько миллисекунд загруженное изображение плавно появляется. |
tintColor (стиль) | .tint_color(..) | ColorValue | Перекрашивает шаблонное изображение (значок, нарисованный одним цветом). | |
objectFit (стиль) | .object_fit(ResizeMode::Contain) | "cover" | "contain" | "fill" | "none" | "scale-down" | "cover" | Как изображение вписывается в свой блок, как CSS object-fit (классы object-*): "cover" заполняет блок и обрезает, "contain" вписывает внутрь, "fill" растягивает, "none" показывает в натуральном размере, "scale-down" — то же, но уменьшает, если изображение больше блока. |
objectPosition (стиль) | .object_position(..), .object_top(), … | string | "center" | Где изображение располагается в своём блоке после того, как objectFit задал размер, как CSS object-position ("top", "25% 75%", "right 10px bottom 20%"); заполняющее изображение обрезается вокруг этой точки. |
onLoadStart | .on_load_start(..) | () => void | Загрузка началась. | |
onProgress | .on_progress(..) | (e: { loaded: number; total: number }) => void | Ход загрузки в байтах (total равно 0, если неизвестно), не чаще чем раз в 100 мс. | |
onLoad | .on_load(..) | (e: { size: PixelSize }) => void | Загружено; размер исходного изображения в пикселях (не декодированного и уменьшенного; размер SVG — в пунктах, в вебе так, как его измеряет браузер); для изображения с вариантами — размер загруженного варианта. | |
onError | .on_error(..) | (e: { message: string; code?: string }) => void | Загрузка не удалась; code равен "ERR_ACCESS_DENIED" для URL, не разрешённого capabilities приложения. | |
onLoadEnd | .on_load_end(..) | () => void | После onLoad или onError. |
ImageStyle добавляет к стилям представления tintColor, который перекрашивает шаблонное изображение (значок, нарисованный одним цветом), и objectPosition, который размещает изображение в его блоке (Позиция изображения).
image(ICON).size(px(24.0)).tint_color(rgb(0x0969da))<Image src={icon} style={{ width: 24, height: 24, tintColor: "#0969da" }} />#События
| Событие | Rust | Данные | Когда |
|---|---|---|---|
onLoadStart | .on_load_start | нет | Загрузка началась. |
onProgress | .on_progress | { loaded, total } (ImageProgress) | Пришли байты (не чаще чем раз в 100 мс). |
onLoad | .on_load | { size } (Size<DevicePixels>) | Изображение показано; размер исходного изображения в пикселях (для SVG — в пунктах, в вебе так, как его измеряет браузер). |
onError | .on_error | { message, code? } (&str, сообщение) | Загрузка или декодирование не удались, либо хост не входит в capabilities приложения (code "ERR_ACCESS_DENIED"). |
onLoadEnd | .on_load_end | нет | После onLoad или 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)}
/>;Изображение с вариантами для разных плотностей пикселей загружает один из своих файлов, и onLoad сообщает размер этого файла в пикселях: 192×192 для logo@3x.png на экране 3× при logo.png размером 64×64 пункта. Когда плотность пикселей меняется и изображение загружает другой вариант, оно сообщает и об этой загрузке: onLoadStart, затем onLoad с размером нового файла (или onError), затем onLoadEnd.
Чтобы узнать размер изображения или загрузить его до показа, в TSX вызывают Image.getSize(src) и Image.prefetch(src) (их несёт тот же Image, что отрисовывает элемент; см. Image API), а в Rust — image_size(src) и prefetch_image(src), с ожиданием в задаче. Для импортированного изображения с вариантами Image.getSize и Image.prefetch загружают вариант, который показывает этот экран, а getSize возвращает размер импортированного файла (64×64 для logo.png).
#Методы ref
Собственных методов нет.
#Особенности платформ
- Ключ кэша включает размер декодирования и радиус размытия: один и тот же URL, показанный в двух размерах, декодируется дважды, а загружается один раз.
- iOS воспроизводит анимированные изображения как покадровую анимацию (до 600 кадров); анимированное изображение с наложенным цветом показывает только первый кадр.
- Android воспроизводит анимированные изображения начиная с API 28 (
AnimatedImageDrawable), без кэширования и без размытия. - На Android границы рисуются поверх изображения, как и на iOS.