要素
Image
Image はアプリのアセットまたは URL の画像を表示します。画像はメインスレッド外でダウンロードおよびデコードされ、ビューが表示するサイズにダウンサンプリングされて、メモリにキャッシュされます。アニメーション GIF、WebP、APNG は再生されます。Rust では image(src) で作成し、TSX では nsh の <Image> を使います。
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、Rust では image(..) と .placeholder(..))には次の値を指定できます。
| 値 | 例 | 説明 |
|---|---|---|
| アセット | Rust asset!("logo.png")、TSX import logo from "./logo.png" | ImageAsset { url, size, scales? }。ポイント単位のサイズを持ち、@2x ファイルはピクセル数の半分として扱われます。logo.png の隣に logo@2x.png / logo@3x.png がある場合、画面のピクセル比に合うバリアントが読み込まれます(ピクセル密度)。.png、.jpg、.jpeg、.gif、.webp、.heic をインポートでき、.svg は width と height または viewBox によってサイズが決まります(SVG 画像)。 |
| URL | "https://example.com/a.jpg" | ダウンロードされてキャッシュされます。同じ URL のダウンロードはビュー間で共有されます。 |
| アセットパス | "asset:/images/a.png" | アプリのアセットのファイル。 |
data: URI | "data:image/png;base64,iVBOR…" | URI 内のバイト列。base64 またはパーセントエンコード(data:image/svg+xml,%3Csvg…)。 |
blob: URL | URL.createObjectURL(blob) | Blob または File(TSX)のバイト列。画像はそれを保持するため、URL を revoke しても画像は全体が表示されたままになります。 |
アプリがすでに持っているバイト列からの画像は、リクエストなしで表示されます。チャットでは、選択した写真をアップロード中は 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 }} />;同じバイト列は 1 つの画像として扱われます。同じ画像を data: URI と blob の両方で使っても、デコードは 1 回で、キャッシュも共有されます。
開発中、アセットは開発サーバーから配信されます。nsh build と nsh package はそれらをアプリにコピーします(Rust クレートは assets/ ディレクトリを registry.assets(assets!()) で同梱します)。詳しくは画像とアセットを参照してください。
#Props
共通 propsに加えて(style の型は ImageStyle):
| Prop | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
src | image(src) | string | ImageAsset | 表示する画像。 | |
placeholder | .placeholder(src) | string | ImageAsset | src の読み込みが完了するまで表示されます。ローカルアセットが最適です。 | |
blurRadius | .blur_radius(px(..)) | number | 画像をこの半径(ポイント)でぼかします(デコード時にメインスレッド外で 1 回だけ適用されます)。 | |
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 ms ごとに通知されます。 | |
onLoad | .on_load(..) | (e: { size: PixelSize }) => void | 読み込み完了。ソース画像のサイズ(ピクセル。デコード・ダウンサンプリング後のサイズではありません。SVG はポイント単位で、Web ではブラウザーが計測した値)。バリアントを持つ画像では、読み込まれたバリアントのサイズです。 | |
onError | .on_error(..) | (e: { message: string; code?: string }) => void | 読み込みに失敗しました。アプリのケイパビリティで許可されていない URL の場合、code は "ERR_ACCESS_DENIED" です。 | |
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 ms ごと)。 |
onLoad | .on_load | { size }(Size<DevicePixels>) | 画像が表示されました。ソースのサイズ(ピクセル。SVG はポイント単位で、Web ではブラウザーが計測した値)。 |
onError | .on_error | { message, code? }(&str、メッセージ) | 読み込みまたはデコードに失敗した、あるいはホストがアプリのケイパビリティの範囲外です(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)}
/>;ピクセル密度バリアントを持つ画像はそのファイルのうち 1 つを読み込み、onLoad はそのファイルのピクセル数を報告します。たとえば 3× の画面では、64×64 ポイントの logo.png に対して logo@3x.png の 192×192 です。ピクセル比が変わって画像が別のバリアントを読み込んだ場合は、その読み込みも報告します。まず onLoadStart、続いて新しいファイルのサイズとともに onLoad(または onError)、最後に onLoadEnd です。
画像のサイズを知りたい場合や、表示前に読み込みたい場合は、TSX では Image.getSize(src) と Image.prefetch(src) を呼び出し(要素を描画するのと同じ Image がこれらを持ちます。Image APIを参照)、Rust では image_size(src) と prefetch_image(src) をタスク内で await します。バリアントを持つインポート済み画像を渡すと、Image.getSize と Image.prefetch はこの画面が表示するバリアントを読み込み、getSize はインポートされたファイルのサイズで解決されます(logo.png では 64×64)。
#Ref メソッド
独自のものはありません。
#プラットフォームに関する注意
- キャッシュキーにはデコードサイズとぼかし半径が含まれます。同じ URL を 2 つのサイズで表示すると、デコードは 2 回行われますが、ダウンロードは 1 回です。
- iOS はアニメーション画像をキーフレームアニメーションとして再生します(最大 600 フレーム)。色を付けたアニメーション画像は最初のフレームのみ表示されます。
- Android は API 28(
AnimatedImageDrawable)以降でアニメーション画像を再生しますが、キャッシュもぼかしもされません。 - Android では、iOS と同様にボーダーは画像の上に描画されます。