元素

Image

Image 显示来自应用资源或 URL 的图片。图片在主线程之外下载和解码,按视图显示的尺寸降采样,并缓存在内存中。动态 GIF、WebP 和 APNG 图片会自动播放。Rust 用 image(src) 创建,TSX 用从 nsh 导入的 <Image>。

el-image.tsx
el-image.tsx运行中
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", "stretch", "center"] 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}
          resizeMode={mode()}
          blurRadius={blur() ? 12 : 0}
          fadeDuration={250}
          onLoadStart={() => log("onLoadStart")}
          onLoad={(e) => log(`onLoad ${Math.round(e.width)} × ${Math.round(e.height)}`)}
          onError={(e) => log(`onError ${e.message}`)}
          onLoadEnd={() => log("onLoadEnd")}
          style={{ ...ui().inset, flex: 1, minHeight: 180, borderRadius: 14 }}
        />
        <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 }}>resizeMode</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::*;

// 应用 crate 中的 assets/logo.png。
const LOGO: ImageAsset = asset!("logo.png");

view()
    .flex_row()
    .gap_3()
    .child(image(LOGO))
    .child(
        image("https://example.com/avatar.jpg")
            .placeholder(LOGO)
            .resize_mode(ResizeMode::Cover)
            .size(px(64.0))
            .rounded_full(),
    )

资源知道自己的尺寸:不设宽高时图片使用该尺寸;只设其中一个时,另一个按资源的宽高比计算。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,以及按 width 和 height 或 viewBox 确定尺寸的 .svg(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: URLURL.createObjectURL(blob)Blob 或 File 的字节(TSX)。图片保有这些字节:撤销 URL 后,正在显示它的图片保持完整。

来自应用已有字节的图片无需请求即可显示。聊天应用在照片上传期间用它的 blob 显示所选照片,上传完成后把 src 换成照片的 URL:

// `data:` URI 中的字节(应用生成或内联收到的图片)。
image(format!("data:image/png;base64,{encoded}")).size(px(64.0))

相同的字节是同一张图片:同一张图片无论作为 data: URI 还是 blob,都只解码一次并共享缓存。

开发期间资源由开发服务器提供;nsh build 和 nsh package 会把它们拷贝进应用(Rust crate 用 registry.assets(assets!()) 发布它的 assets/ 目录)。参见图片与资源。

#属性

除通用属性外(style 的类型为 ImageStyle):

属性Rust类型默认值说明
srcimage(src)string | ImageAsset图片。
resizeMode.resize_mode(ResizeMode::Contain)"cover" | "contain" | "stretch" | "center" | "scale-down""cover"图片如何适配其盒子:"center" 以原始尺寸居中显示(CSS 的 object-fit: none);"scale-down" 同样居中,较大时缩小以适应。
placeholder.placeholder(src)string | ImageAssetsrc 加载完成前显示的图片;最好是本地资源。
blurRadius.blur_radius(px(..))number以该半径(点)模糊图片(在解码时于主线程外处理一次)。
fadeDuration.fade_duration(ms)numberiOS 0,Android 300图片加载完成后淡入的时长,单位毫秒。
tintColor(样式).tint_color(..)ColorValue为模板图片(单色绘制的图标)重新着色。
objectPosition(样式).object_position(..)、.object_top() 等string"center"resizeMode 确定尺寸后图片在盒子中的位置,同 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: { width: number; height: number }) => void加载完成;给出源图片的像素尺寸(不是解码、降采样后的尺寸;SVG 为它的点尺寸,在 Web 上为浏览器测得的尺寸);带有变体的图片给出所加载变体的尺寸。
onError.on_error(..)(e: { message: string }) => void加载失败。
onLoadEnd.on_load_end(..)() => void在 onLoad 或 onError 之后。

ImageStyle 在视图样式之上增加了 tintColor,为模板图片(单色绘制的图标)重新着色;以及 objectPosition,决定图片在盒子中的位置(图片位置)。

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

#事件

事件Rust载荷触发时机
onLoadStart.on_load_start无开始加载。
onProgress.on_progress{ loaded, total }(ImageProgress)收到数据(最多每 100 ms 一次)。
onLoad.on_load{ width, height }(Size<Points>)图片已显示;给出源图片的像素尺寸(SVG 为点,在 Web 上为浏览器测得的尺寸)。
onError.on_error{ message }(&str)加载或解码失败,或主机不在应用的能力范围内。
onLoadEnd.on_load_end无在 onLoad 或 onError 之后。
let state = signal(SharedString::from("加载中"));

image(url)
    .w_full()
    .aspect_ratio(16.0 / 9.0)
    .on_load(move |size: &Size<Points>| state.set(format!("{}×{}", size.width.0, size.height.0).into()))
    .on_error(move |message: &str| state.set(message.to_string().into()))

带有像素密度变体的图片会加载其中一个文件,onLoad 报告的是该文件的像素尺寸:对于 64×64 点的 logo.png,在 3× 屏幕上报告 logo@3x.png 的 192×192。像素比变化、图片加载另一个变体时,这次加载同样会触发事件:先是 onLoadStart,然后是带有新文件尺寸的 onLoad(或 onError),最后是 onLoadEnd。

如果需要在显示前获取图片尺寸或预先加载,TSX 调用 Image.getSize(src) 和 Image.prefetch(src)(它们就挂在渲染这个元素的同一个 Image 上;参见 Image API),Rust 在任务中 await image_size(src) 和 prefetch_image(src)。 传入带有变体的导入图片时,Image.getSize 和 Image.prefetch 加载当前屏幕显示的那个变体,getSize resolve 为导入文件的尺寸(logo.png 为 64×64)。

#ref 方法

没有自己的方法。

#平台说明

  • 缓存键包含解码尺寸和模糊半径:同一 URL 以两种尺寸显示会解码两次,但只下载一次。
  • iOS 以关键帧动画播放动态图片(最多 600 帧);着色的动态图片显示第一帧。
  • Android 从 API 28 起播放动态图片(AnimatedImageDrawable),这些图片直接播放,不经缓存和模糊。
  • 在 Android 上边框绘制在图片之上,与 iOS 一致。