이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

요소

Image

Image shows a picture from the app’s assets or from a URL. Images are downloaded and decoded off the main thread, downsampled to the size the view shows them at, and cached in memory. Animated GIF, WebP and APNG images play. Rust makes one with image(src), TSX with <Image> from nsh.

el-image.tsx
el-image.tsxLive
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(),
    )

An asset knows its size: without a width or height the image takes it, and with one of them the other follows the asset’s aspect ratio. A URL’s size is known once it loads, so give remote images a width and a height (or an aspect ratio with one of them).

#Sources

The source (src and placeholder; image(..) and .placeholder(..) in Rust) takes:

ValueExampleDescription
An assetRust asset!("logo.png"), TSX import logo from "./logo.png"An ImageAsset { url, size, scales? }, sized in points: a @2x file counts half its pixels. With logo@2x.png / logo@3x.png next to logo.png, the variant for the screen’s pixel ratio is loaded (pixel densities). .png, .jpg, .jpeg, .gif, .webp and .heic can be imported, and .svg, sized by its width and height or viewBox (SVG images).
A URL"https://example.com/a.jpg"Downloaded and cached. Downloads of the same URL are shared between views.
An asset path"asset:/images/a.png"A file of the app’s assets.
A data: URI"data:image/png;base64,iVBOR…"Bytes in the URI, base64 or percent-encoded (data:image/svg+xml,%3Csvg…).
A blob: URLURL.createObjectURL(blob)The bytes of a Blob or File (TSX). The image keeps them: revoking the URL leaves an image that shows it whole.

An image from bytes the app already has shows without a request. A chat shows a picked photo from its blob while it uploads, then switches src to the photo’s 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))

Equal bytes are one image: the same picture as a data: URI and as a blob is decoded once and shares the cache.

During development assets are served by the dev server; nsh build and nsh package copy them into the app (a Rust crate ships its assets/ directory with registry.assets(assets!())). See images and assets.

#Props

Besides the common props (with style typed as ImageStyle):

PropRustTypeDefaultDescription
srcimage(src)string | ImageAssetThe image.
placeholder.placeholder(src)string | ImageAssetShown until src has loaded; best a local asset.
blurRadius.blur_radius(px(..))numberBlurs the image by this radius in points (applied once while decoding, off the main thread).
fadeDuration.fade_duration(ms)numberiOS 0, Android 300How long a loaded image fades in, in milliseconds.
tintColor (style).tint_color(..)ColorValueRecolors a template image (an icon drawn in one color).
objectFit (style).object_fit(ResizeMode::Contain)"cover" | "contain" | "fill" | "none" | "scale-down""cover"How the image fits its box, as CSS’s object-fit (object-* classes): "cover" fills it and crops, "contain" fits inside it, "fill" stretches, "none" shows it at its natural size, "scale-down" the same, shrunk to fit where it is larger.
objectPosition (style).object_position(..), .object_top(), …string"center"Where the image sits in its box once objectFit has sized it, as CSS’s object-position ("top", "25% 75%", "right 10px bottom 20%"); a covering image is cropped around it.
onLoadStart.on_load_start(..)() => voidLoading began.
onProgress.on_progress(..)(e: { loaded: number; total: number }) => voidDownload progress in bytes (total is 0 when unknown), at most every 100 ms.
onLoad.on_load(..)(e: { size: PixelSize }) => voidLoaded; the source image’s size in pixels (not the decoded, downsampled one; an SVG’s size in points, on the web as the browser measures it); for an image with variants, the loaded variant’s.
onError.on_error(..)(e: { message: string; code?: string }) => voidLoading failed; code is "ERR_ACCESS_DENIED" for a URL the app’s capabilities do not grant.
onLoadEnd.on_load_end(..)() => voidAfter onLoad or onError.

ImageStyle adds tintColor to the view styles, which recolors a template image (an icon drawn in one color), and objectPosition, which places the image in its box (Image position).

image(ICON).size(px(24.0)).tint_color(rgb(0x0969da))

#Events

EventRustPayloadWhen
onLoadStart.on_load_startnoneLoading began.
onProgress.on_progress{ loaded, total } (ImageProgress)Bytes arrived (at most every 100 ms).
onLoad.on_load{ size } (Size<DevicePixels>)The image is shown; its source size in pixels (an SVG’s in points, on the web as the browser measures it).
onError.on_error{ message, code? } (&str, the message)Loading or decoding failed, or the host is outside the app’s capabilities (code "ERR_ACCESS_DENIED").
onLoadEnd.on_load_endnoneAfter onLoad or 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()))

An image with pixel-density variants loads one of its files, and onLoad reports that file’s pixels: 192×192 for logo@3x.png on a 3× screen, for a 64×64 point logo.png. When the pixel ratio changes and the image loads another variant, it reports that load too: onLoadStart, then onLoad with the new file’s size (or onError), then onLoadEnd.

To know an image’s size or load it before showing it, TSX calls Image.getSize(src) and Image.prefetch(src) (the same Image that renders the element carries them; see the Image API), and Rust image_size(src) and prefetch_image(src), awaited in a task. Given an imported image with variants, Image.getSize and Image.prefetch load the variant this screen shows, and getSize resolves with the imported file’s size (64×64 for logo.png).

#Ref methods

None of its own.

#Platform notes

  • The cache key includes the decode size and the blur radius: the same URL shown at two sizes is decoded twice but downloaded once.
  • iOS plays animated images as a keyframe animation (up to 600 frames); a tinted animated image shows its first frame.
  • Android plays animated images from API 28 (AnimatedImageDrawable), uncached and unblurred.
  • On Android borders are drawn above the image, as on iOS.