本頁由機器翻譯,可能有錯誤。 閱讀英文原文

API

nsh

nsh 是核心模組,包含:響應式 API(signal、computed、effect、context)、mount、token、宿主元素 View、Text、Scroll 和 Overlay、可選取的視圖、安全區域與鍵盤、動畫、裝置及其環境、PanResponder、剪貼簿、相片選取器、語音辨識、儲存、宿主通道、鍵盤快速鍵、原生元件與模組、無障礙功能,以及元素、樣式和事件的 TypeScript 型別。響應式圖本身在 Rust 中原生運行;讀取 signal 是對執行環境發出的一次呼叫,只需數奈秒,只有你自己的閉包才會在 JavaScript 直譯器中運行。

import { batch, cleanup, computed, context, effect, ErrorBoundary, mount, Overlay, Scroll, signal, split, Text, token, untracked, View } from "nsh";
import { Input } from "nsh/base";

NSH 的響應式機制與 SolidJS 採用相同的模型:元件函式只會執行一次,UI 中讀取了 signal 的部分會在這些 signal 改變時就地更新。元件不會重新渲染,也沒有虛擬 DOM。指南請參閱響應式。

#signal

function signal<T>(value: T, options?: SignalOptions<T>): Signal<T>;
function signal<T = undefined>(): Signal<T | undefined>;

interface Signal<T> {
  (): T;                              // read (tracked)
  set(value: T): void;                // write
  update(fn: (value: T) => T): void;  // write from the current value
}

interface SignalOptions<T> {
  /** When a new value counts as a change (default `===`); `false`: always. */
  equals?: false | ((previous: T, next: T) => boolean);
}

signal 保存一個值。呼叫它即可讀取該值,而在追蹤範圍內(JSX 表達式、computed、effect)讀取時,會讓該範圍訂閱它。set 和 update 會寫入新值,所有讀取過它的地方都會隨之更新。

import { mount, signal, Text, View } from "nsh";

function Counter() {
  const count = signal(0);
  return (
    <View style={{ padding: 24, gap: 12 }}>
      <Text>Count: {count()}</Text>
      <Text onPress={() => count.update((n) => n + 1)}>Add one</Text>
    </View>
  );
}

mount(Counter);

預設情況下,寫入與目前值 === 的值時不會有任何作用。傳入 equals: false 可使每次寫入都發出通知(適用於就地修改物件後再次設定同一個物件的情況),也可以傳入你自己的比較函式:

const items = signal<string[]>([], { equals: false });
const point = signal({ x: 0, y: 0 }, { equals: (a, b) => a.x === b.x && a.y === b.y });

#computed

function computed<T>(fn: (previous: T | undefined) => T, options?: SignalOptions<T>): Accessor<T>;

interface Accessor<T> {
  (): T;
}

由 signal 衍生出來的值。當 fn 讀取過的 signal 改變時,它會再次執行;computed 值的讀取者只有在其結果改變時才會更新(以 equals 比較,預設為 ===)。fn 會接收上一次的結果。

const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => `${first()} ${last()}`);
full(); // "Ada Lovelace"

當衍生計算開銷較大,或需要在多處讀取時,請使用 computed。對於簡單的表達式,使用普通函式(const full = () => first() + " " + last())同樣具有響應性。

#effect

function effect(fn: () => void): void;

在渲染後執行 fn,之後每當它讀取過的 signal 改變時再次執行。effect 用於處理副作用:記錄日誌、計時器、與原生模組通訊。effect 屬於建立它的元件(或 effect),並會隨之一併被銷毀。

const query = signal("");
effect(() => {
  console.log("query is now", query());
});

若要在 effect 再次執行之前(或其元件移除時)還原某些操作,請在 effect 內呼叫 cleanup。

#batch

function batch<T>(fn: () => T): T;

執行 fn,並在結束時一次過套用其中所有寫入,讓讀取者只更新一次,而不是每次寫入都更新。回傳 fn 的回傳值。

batch(() => {
  first.set("Grace");
  last.set("Hopper");
}); // `full` recomputes once

#untracked

function untracked<T>(fn: () => T): T;

執行 fn,但不會讓目前範圍訂閱其中讀取的內容。當你想在 effect 或 JSX 表達式內讀取某個 signal 而不依賴它時,請使用此函式。

effect(() => {
  // Runs again when `query` changes, not when `page` changes.
  search(query(), untracked(() => page()));
});

#cleanup

function cleanup(fn: () => void): void;

註冊 fn,使其在目前的 effect 再次執行之前,或目前的元件或 effect 移除時執行。

function Clock() {
  const now = signal(new Date());
  const timer = setInterval(() => now.set(new Date()), 1000);
  cleanup(() => clearInterval(timer));
  return <Text>{now().toLocaleTimeString()}</Text>;
}

#context

function context<T>(defaultValue: T): Context<T>;

interface Context<T> extends Accessor<T> {
  Provider: (props: { value: T; children?: JSX.Element }) => JSX.Element;
}

元件向其渲染的所有內容提供的值,無論層級多深。呼叫 context(Theme())會讀取最近的提供者所提供的值;若沒有提供者,則得到 defaultValue。

import { context, mount, signal, Text, View } from "nsh";

const Theme = context<"light" | "dark">("light");

function Label(props: { text: string }) {
  return <Text style={{ color: Theme() === "dark" ? "#fff" : "#000" }}>{props.text}</Text>;
}

function App() {
  const dark = signal(false);
  return (
    <Theme.Provider value={dark() ? "dark" : "light"}>
      <View style={{ flex: 1, padding: 24, backgroundColor: dark() ? "#000" : "#fff" }}>
        <Label text="Hello" />
        <Text onPress={() => dark.update((d) => !d)}>Toggle</Text>
      </View>
    </Theme.Provider>
  );
}

mount(App);

#mount

function mount<P extends object>(app: Component<P>, props?: P): () => void;

將 app 渲染到畫面(視窗,或應用程式所嵌入的 NshAppView / NshView),並回傳一個用來移除它的函式。每個應用程式的入口檔案都以 mount(App) 結尾。

mount(App);

#split

function split<T extends object, K extends readonly (keyof T)[]>(
  props: T,
  keys: K,
): [Pick<T, K[number]>, Omit<T, K[number]>];

將 props 物件拆分為 keys 中列出的 props 和其餘 props。兩部分都保持響應性:值會改變的 props 是 getter,而 split 會將它們保留為 getter。當元件自行處理部分 props 並把其餘的傳遞下去時,請使用它。若解構 props(const { label } = props),只會讀取一次,並會失去後續的更新。

import { split, Text, View } from "nsh";
import { Input, type InputProps } from "nsh/base";

function LabeledInput(props: InputProps & { label: string }) {
  const [own, rest] = split(props, ["label"]);
  return (
    <View style={{ gap: 4 }}>
      <Text>{own.label}</Text>
      <Input {...rest} />
    </View>
  );
}

#ErrorBoundary

function ErrorBoundary(props: {
  fallback: JSX.Element | ((error: unknown, reset: () => void) => JSX.Element);
  children?: JSX.Element;
}): JSX.Element;

渲染其子元素;當其下任何內容拋出錯誤(元件主體、綁定、effect 或事件處理函式)時,則渲染 fallback。失敗的部分會被移除,並由 fallback 取代顯示。函式 fallback 會以 fallback(error, reset) 的形式被呼叫,傳入拋出的值和 reset(用於再次渲染子元素),無論它宣告了哪些參數;fallback 元素則按原樣顯示。錯誤會交給其上最近的 boundary 處理,而 fallback 中的錯誤則交給再上一層的 boundary。Rust 元件使用 catch_error(錯誤邊界)。

import { ErrorBoundary, Text, View } from "nsh";

function Feed() {
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <View>
          <Text>{error instanceof Error ? error.message : String(error)}</Text>
          <Text onPress={reset}>Try again</Text>
        </View>
      )}
    >
      <Posts />
    </ErrorBoundary>
  );
}

#token

function token<K extends keyof Tokens>(name: K): Tokens[K];

取得應用程式主題(在 nsh.json 中為 "theme",基於 shadcn/ui 的 token)的設計 token name,並依目前顯示的外觀返回對應值: 顏色以 #rrggbbaa 表示,長度以點數表示,字型則以其字體系列名稱表示。名稱即主題的 CSS 變數, 不含 --(primary、muted-foreground、radius、font-mono,以及應用程式自訂的,例如 up)。讀取 token 具有 響應性:讀取了 token 的元件、memo 或 effect,會在主題或外觀改變時再次執行。 Tokens 會列出應用程式的 token 及其型別;NSH 會根據主題將它寫入 .nsh/types/nsh/tokens.d.ts。至於 class,請直接寫出 token 名稱(bg-primary、rounded-(--radius)):主題 token。

import { effect, token } from "nsh";

effect(() => console.log("primary is", token("primary"), "radius", token("radius")));

#View, Text, Scroll, Overlay

function View(props: ViewProps): JSX.Element;
function Text(props: TextProps): JSX.Element;
function Scroll(props: ScrollProps): JSX.Element;
function Overlay(props: OverlayProps): JSX.Element;

nsh 匯出的宿主元素,以標籤形式書寫:<View>、<Text>、<Scroll>、<Overlay>(將其 子元素顯示在視窗最上層,並相對於錨點定位:Overlay)。對於透過 import 指向其中之一的標籤(別名亦然),編譯器會 就地建立原生視圖,因此匯入它們在執行時不會產生 任何開銷。它們與其他元件一樣都是元件,因此也可以被傳遞(const Tag = inline ? Text : View)。 nsh 以相同方式匯出其他基本元件:List、Image、 Canvas、WebView和 Video,各自有專屬的參考 頁面。它們的 props 記載於元素之下。文字輸入框 Input 來自 nsh/base。

#安全區域與鍵盤

CSS 的環境變數可讓內容避開系統 UI,可用於任何視圖的 padding 和 margin (padding、paddingTop 等,即相同的 margin 鍵;Tailwind 的 p* / m* 任意值;Rust 中的 Styled 方法 .p_safe_area()、.pt_safe_area_plus(..)、.pb_keyboard() 等)。它們的值是 EnvValue: env(name)、env(name, <length>)、calc(env(name) + <length>) 和 calc(<length> + env(name))。

變數 值
safe-area-inset-top、-right、-bottom、-left 節點所渲染的根視圖的內縮距離:最近的頁面(在其標題列之下)、最近的對話框,否則為畫面的安全區域。請參閱安全區域。
keyboard-inset-height 軟鍵盤在畫面上的高度,隱藏時為 0;變化會與鍵盤一同以原生方式播放動畫。請參閱鍵盤。
<View style={{ flex: 1, paddingTop: "env(safe-area-inset-top)", paddingBottom: "env(keyboard-inset-height)" }}>…</View>

#safeArea 與 safeAreaInsets

safeArea() 是畫面的安全區域(Rust 中為 safe_area());safeAreaInsets() 是元件所在位置的內縮距離 (Rust 中為 cx.insets()),即 safe-area-inset-* 變數的值。兩者都是具響應性的 Edges ({ top, right, bottom, left },單位為點)。請參閱 safeArea 與 safeAreaInsets。

#Keyboard 與 keyboardFrame

Keyboard.dismiss() 會隱藏軟鍵盤(Rust 中為 dismiss_keyboard()),keyboardFrame() 則告訴你鍵盤遮蓋了 多少畫面(Rust 中為 keyboard_height() 和 keyboard_duration())。請參閱 隱藏鍵盤和 鍵盤的高度。

#可選取的視圖

<View selectable>(Rust 中為 view().selectable(true))會讓其內的文字可以一同被選取,就像網頁一樣: 一次選取可橫跨多個視圖、Text 元素和 TextView,並依閱讀順序排列。請參閱 文字選取。

#動畫

動畫 API 在 UI 執行緒上播放動畫。你建立一個動畫值,將它(或由它衍生的值)綁定到樣式屬性,然後啟動動畫;此後每一幀都在原生層計算, 無需執行應用程式的程式碼。即使引擎或 JS 執行緒繁忙,也不會令動畫卡頓,而且動畫不會為應用程式帶來每幀的運算負擔。Rust 程式碼使用 nsh_core::prelude 中的 animated_value()、motion()、Animation、Spring、Timing、KeyframeTiming、MotionPreset 和 Easing,並透過元素的 .bind_* 方法綁定值;TSX 則從 nsh 匯入它們。

use nsh_core::{element::LayoutAnimation, prelude::*}; // animated_value, motion, Animation, Spring, Timing, Keyframe, KeyframeTiming, MotionPreset, MotionSpec, Easing, animate_layout

指南請參閱動畫。

#動畫值可以驅動什麼

位置 Rust TSX
opacity .bind_opacity(&value) style={{ opacity: value }}
backgroundColor .bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()])) style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
borderColor、color .bind_border_color(&node)、.bind_color(&node) 相同:顏色節點。borderColor 會為每一邊著色;Text 的 color 會為整段文字著色
transform .bind_translate_x(&value)、.bind_translate_y、.bind_scale、.bind_rotate(度) translateX、translateY、scale、scaleX、scaleY、rotate、rotateX、rotateY、rotateZ 步進:transform: [{ translateX: value }]
捲動位置 捲動視圖或列表上的 .scroll_value(&value) <Scroll scrollValue={value}> 和 <List scrollValue={value}> 會在每一幀將捲動偏移量寫入其中
手勢 .pan_values(Some(&x), Some(&y))、.pinch_value(&scale)、.rotation_value(&angle) 任何元素上的 pan={{ x, y }}、pinch={{ scale }}、rotation={{ angle }}:手勢會直接寫入這些值(請參閱觸控與手勢)
視圖黏附的位置 .sticky().bind_sticky_top(&value)(在水平捲動視圖中為 .bind_sticky_left)、列表上的 .bind_sticky_header_offset(&value) style={{ position: "sticky", top: value }}、<List stickyHeaderOffset={value}>:黏附視圖和標題會黏附在距離視口起點該數值的位置,並在每一幀跟隨該值
Canvas painter 呼叫的動畫參數 canvas 繪圖呼叫的任何數字或顏色參數
Shader .uniform(name, &value) shader 的 struct Uniforms 的成員:uniforms={{ name: value }}(數字,或用於 vec3f / vec4f 的顏色節點)

其他樣式屬性只接受普通值;若要為版面本身(尺寸、在流中的位置)加入動畫,請使用 LayoutAnimation。

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

建立一個動畫值,起始值為 value(預設為 0)。Rust:animated_value(value),即由建立它的元件持有的 Copy 控制代碼。

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let right = stored(false);
    let toggle = move || {
        let to_right = !right.get();
        right.replace(to_right);
        let _ = x.spring(if to_right { 200.0 } else { 0.0 }, Spring::default());
    };
    view()
        .flex_1()
        .p_6()
        .pt_12()
        .gap_4()
        .child(view().size(px(60.0)).rounded(px(12.0)).bg(rgb(0x6366f1)).bind_translate_x(&x))
        .child(text("Move").on_press(toggle))
}

#AnimatedValue

class AnimatedValue extends AnimatedNode {
  constructor(value: number);
  set(value: number): void;
  stop(): Promise<{ value: number }>;
  getValue(): Promise<number>;
  setOffset(offset: number): void;
  flattenOffset(): void;
  extractOffset(): void;
  timing(to: number, options?: { duration?: number; easing?: Easing; delay?: number }): Promise<{ finished: boolean }>;
  spring(to: number, options?: { stiffness?: number; damping?: number; mass?: number; velocity?: number }): Promise<{ finished: boolean }>;
  decay(options?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): Promise<{ finished: boolean }>;
  keyframes(keyframes: KeyframeValue[], options?: KeyframeTiming): Promise<{ finished: boolean }>;
}
方法 Rust 說明
set(value) set(value) 直接跳到 value,並停止任何正在執行的動畫。
stop() stop(),然後 value().await 將正在執行的動畫停在目前位置;以 { value }(即該處的值)解析。
getValue() value().await 以目前的值(含其偏移量)解析,於 UI 執行緒上讀取。
setOffset(offset) set_offset(offset) 加在值上的偏移量:綁定看到的是 value + offset;set、動畫和拖曳移動的是值本身。
flattenOffset() flatten_offset() 將偏移量加到值上,並將偏移量歸零。
extractOffset() extract_offset() 將值移入偏移量,並將值歸零(之後的拖曳會從上一次結束的位置開始)。
timing(to, options) timing(to, ms, easing)、timing_with(to, Timing { … }) 沿緩動曲線動畫至 to。duration 以毫秒計(預設 300),easing(預設 "easeInOut"),delay 以毫秒計(預設 0)。
spring(to, options) spring(to, Spring { … }) 以彈簧效果移向 to。stiffness(預設 170)、damping(預設 26)、mass(預設 1)、velocity(初始速度,單位為每秒)。
decay(options) decay(velocity, deceleration)、decay_within(velocity, deceleration, (min, max), bounce) 以 velocity(單位為每秒)的速度滑行,並每毫秒減速 deceleration(預設 0.998)。適用於快速滑動(fling)之後,速度取自 onPanEnd。配合 clamp: [min, max] 時,它會在邊界處停止,或從邊界彈回並保留 bounce(0..1,預設 0)的速度。
keyframes(frames, options) keyframes(frames, KeyframeTiming) 像 CSS 關鍵影格一樣播放關鍵影格:每個區段各有一個緩動,iterations 次,時間為 direction。

每個動畫方法都會回傳一個 promise,以 { finished } 解析:動畫完成時為 true,被停止時(由 stop、set 或同一個值的另一個動畫停止)為 false。在 Rust 中它回傳 AnimationEnd,任務可 await 它以取得相同的 bool。

let opacity = animated_value(1.0);
spawn(async move {
    if opacity.timing(0.0, 200.0, Easing::EaseInOut).await {
        visible.set(false);
    }
});

#AnimatedNode

class AnimatedNode {
  interpolate(config: {
    inputRange: number[];
    outputRange: number[] | string[];
    extrapolate?: Extrapolation;
    extrapolateLeft?: Extrapolation;
    extrapolateRight?: Extrapolation;
    easing?: Easing;
  }): AnimatedNode;
  diffClamp(min: number, max: number, options?: { snap?: boolean }): AnimatedNode;
  add(b: AnimatedNode | number): AnimatedNode;
  sub(b: AnimatedNode | number): AnimatedNode;
  mul(b: AnimatedNode | number): AnimatedNode;
  div(b: AnimatedNode | number): AnimatedNode;
  mod(b: AnimatedNode | number): AnimatedNode;
}

type Extrapolation = "extend" | "clamp" | "identity";

原生動畫圖中的一個節點:可以是 AnimatedValue,也可以是由其他節點衍生出來的值。衍生節點會在其輸入改變時於 UI 執行緒上求值。

方法 Rust 說明
interpolate(config) interpolate(&input, &output)、interpolate_colors(&input, &colors)、interpolate_with(&input, output, Interpolation { … }) 透過分段範圍對映輸入。outputRange 可包含數字、顏色("#ff0000"、"rgba(…)")或角度("45deg"、"1rad")。超出 inputRange 時預設會被限制在範圍內;extrapolate(或 extrapolateLeft / extrapolateRight)可以是 "extend"(延伸該直線)、"clamp" 或 "identity"(原樣傳遞輸入)。easing 為每個區段塑形。Rust 接受數字、主題顏色(會跟隨外觀)以及以度數表示的角度,用於 bind_rotate。
diffClamp(min, max, options?) diff_clamp(min, max)、diff_clamp_with(min, max, DiffClamp::snapping()) 跟隨輸入的變化,但將結果保持在 min..max 之內:例如標題列在內容向上捲動時隱藏,一旦向下捲動便立即重新出現。snap: true 會在其捲動視圖靜止時,於 200 毫秒內將它定位到較近的邊界,而 max 僅在輸入達到 max 之後才會這樣做。
add、sub、mul、div add、sub、mul、div 與另一個節點或常數進行算術運算。
mod(b) modulo(b) 取模,對於正的 b 結果永不為負(使角度保持在 0..360)。
let scroll_y = animated_value(0.0);
let header = scroll_y.diff_clamp(0.0, 44.0);

view()
    .flex_1()
    .child(scroll().scroll_value(&scroll_y).flex_1() /* rows */)
    .child(view().h(px(44.0)).bind_translate_y(&header.mul(-1.0)))

#Keyframes

interface Keyframe {
  offset?: number;          // 0..1 of an iteration; missing offsets spread evenly
  value: number | string;   // a number, or an angle ("45deg", "0.5turn") in degrees
  easing?: Easing;          // the segment that starts here
}
type KeyframeValue = number | string | Keyframe;

interface KeyframeTiming {
  duration?: number;        // one iteration, ms (300)
  delay?: number;           // before the first iteration, ms (0)
  easing?: Easing;          // segments without their own (CSS "ease")
  iterations?: number | "infinite";   // 1
  direction?: "normal" | "reverse" | "alternate" | "alternate-reverse";
}

value.keyframes(frames, options) 透過關鍵影格播放一個值,並以 { finished } 完成,表示是否播放完畢;Animated.keyframes(value, { keyframes, ...options }) 等同組合動畫中的一個步驟。Rust:value.keyframes(frames, KeyframeTiming) 和 Animation::keyframes(&value, frames, KeyframeTiming),附有 Keyframe::new(value)(平均分佈)、Keyframe::at(offset, value) 和 .ease(easing),以及 KeyframeTiming::new(ms),附有 .delay(ms)、.easing(e)、.iterations(Some(n))、.infinite() 和 .direction(PlaybackDirection::…)。

它們的播放方式與 CSS 關鍵影格相同:每條緩動只作用於一個區段(關鍵影格的緩動作用於由它開始的區段,其餘區段則使用時序的緩動);關鍵影格中缺少的偏移量會平均分佈,第一個位於 0,最後一個位於 1;每次迭代都先跳到其第一個關鍵影格,若沒有位於 0 的關鍵影格,數值則由當前位置開始;在 delay 期間,數值維持在第一個關鍵影格(CSS animation-fill-mode: backwards);反向迭代會倒序播放各區段,並將其曲線反轉;數值停留在最後一次迭代結束的位置。

let y = animated_value(0.0);
y.keyframes(
    [Keyframe::new(0.0), Keyframe::at(0.3, -20.0).ease(Easing::EaseIn), Keyframe::new(0.0)],
    KeyframeTiming::new(600.0).iterations(Some(3)),
);

#createMotion

function createMotion(options?: MotionOptions): Motion;

interface MotionOptions extends MotionPlayOptions {
  initial?: { [C in MotionChannel]?: number };   // where channels start (the others at rest)
  enter?: MotionName | MotionSpec;   // start where it starts and play it at once
}

interface MotionPlayOptions extends KeyframeTiming {
  ignoreReduceMotion?: boolean;      // play even when the user asks for less motion
}

interface Motion {
  readonly opacity: AnimatedValue;
  readonly translateX: AnimatedValue;
  readonly translateY: AnimatedValue;
  readonly rotate: AnimatedValue;    // degrees
  readonly rotateX: AnimatedValue;
  readonly rotateY: AnimatedValue;
  readonly scale: AnimatedValue;
  readonly scaleX: AnimatedValue;
  readonly scaleY: AnimatedValue;
  readonly style: { opacity: AnimatedValue; transform: MotionTransform };
  readonly transform: MotionTransform;
  play(motion: MotionName | MotionSpec, options?: MotionPlayOptions): Promise<{ finished: boolean }>;
  animation(motion: MotionName | MotionSpec, options?: MotionPlayOptions): CompositeAnimation;
  stop(): void;
  reset(): void;
}

建立一個 motion:為視圖的不透明度和變換的每個通道各建立一個動畫值,初始處於靜止狀態(不透明度和縮放為 1,其餘為 0),並提供可在這些通道上以原生方式播放的 motion。使用 style={motion.style} 綁定,或將 motion.style 或 motion.transform 展開到樣式中;變換會先套用平移,再套用旋轉,最後套用縮放,其中 scale 會併入 scaleX 和 scaleY:這佔用了視圖 8 個變換步驟中的 7 個,餘下一個留給你自己使用([{ perspective: 600 }, ...motion.transform] 用於 3D 翻轉)。initial 設定各通道的起始位置({ opacity: 0 };其餘通道由靜止狀態開始)。使用 enter 時,它所移動的通道會由該 motion 的起點開始(經過 initial),並立即播放,其餘選項則作為它的時序(delay、duration 等)。motion 只會移動不透明度和變換:展開 motion.style 會設定視圖的 opacity,因此若要保留自己設定的不透明度,請只綁定 motion.transform;顏色則應使用動畫值和 interpolate 來移動。Rust:motion(),這是由建立它的元件保管的 Copy 控制代碼,以 .bind_motion(&m) 綁定;motion().entering(spec) 負責進場。

成員 Rust(Motion) 說明
opacity、translateX、… scaleY value(MotionChannel::Opacity)、… 各通道的動畫值。
style、transform 元素上的 .bind_motion(&m) 綁定。
play(motion, options?) play(spec)、play_ignoring_reduce_motion(spec) 按名稱播放預設,或在各通道上播放 MotionSpec;新的 motion 會中斷正在播放的 motion。options 會更改其時序。以 { finished } 完成:播放完畢時為 true,被其他操作中止時為 false(Rust 中為 AnimationEnd)。
animation(motion, options?) animation(spec) 同一個 motion,但以 CompositeAnimation 形式提供,尚未開始,可用於組合:Animated.stagger(60, cards.map((m) => m.animation("fadeInUp")), { from: "center" })(Rust:Animation::stagger_from(60.0, StaggerFrom::Center, cards.iter().map(|m| m.animation(MotionPreset::FadeInUp))))。是否減少動態效果,於建立時決定。
stop() stop() 在當前位置停止。
reset() reset() 停止並將每個通道還原到靜止狀態。

當使用者要求減少動態效果(reduceMotion())時,play、animation 和 enter 會立即跳到 motion 的終點,不產生移動,除非已設定 ignoreReduceMotion(在選項或 motion 上設定)。

let card = motion().entering(MotionPreset::FadeInUp.spec().timing(|t| t.delay(80.0)));
view().size(px(120.0)).rounded(px(16.0)).bg(rgb(0x0969da)).bind_motion(&card).on_press(move || {
    card.play(MotionPreset::Pulse);
})

#motions

const motions: Readonly<Record<MotionName, MotionPreset>>;

type MotionChannel = "opacity" | "translateX" | "translateY" | "rotate" | "rotateX" | "rotateY" | "scale" | "scaleX" | "scaleY";

interface MotionSpec extends KeyframeTiming {
  keyframes: { [C in MotionChannel]?: KeyframeValue[] } | MotionFrame[];
  ignoreReduceMotion?: boolean;
}
type MotionFrame = { [C in MotionChannel]?: number | string } & { offset?: number; easing?: Easing };

interface MotionPreset extends MotionSpec {
  kind: "enter" | "exit" | "attention" | "loop";
}

以資料形式提供的現成 motion:motion.play("shake") 會播放 motions.shake。MotionName 是以下其中之一:fadeIn、fadeInUp、fadeInDown、fadeInLeft、fadeInRight、zoomIn、popIn、bounceIn、slideInUp、slideInDown、flipInX、flipInY(進場);fadeOut、fadeOutUp、fadeOutDown、fadeOutLeft、fadeOutRight、zoomOut、popOut、slideOutDown、slideOutUp(退場);shake、headShake、wiggle、swing、pulse、heartbeat、rubberBand、jello、tada、jump、flash(引起注意);spin、float、breathe、sway、bob、glow(循環)。Animation 說明每一個 motion。展開預設即可修改它:motion.play({ ...motions.fadeInUp, duration: 600 })。

MotionSpec 是你自訂的 motion:可以是每個通道的關鍵影格({ opacity: [0, 1], translateY: [24, 0] }),也可以是如 Web Animations API 所寫的影格清單([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }],沒有偏移量的影格會按其在清單中的位置決定位置),並附上關鍵影格時序。Rust:MotionSpec::new(KeyframeTiming),附有 .values(channel, values)(平均分佈)、.track(channel, keyframes) 和 .timing(|t| …);MotionPreset::FadeInUp(或 .spec())是預設,MotionChannel 則為各通道命名。

let rise = MotionSpec::new(KeyframeTiming::new(400.0))
    .values(MotionChannel::Opacity, [0.0, 1.0])
    .track(MotionChannel::TranslateY, [Keyframe::at(0.0, 24.0).ease(Easing::EaseOut), Keyframe::at(1.0, 0.0)]);
card.play(rise);

#Animated

const Animated: {
  timing(value: AnimatedValue, config: { toValue: number; duration?: number; easing?: Easing; delay?: number }): CompositeAnimation;
  spring(value: AnimatedValue, config: { toValue: number; stiffness?: number; damping?: number; mass?: number; velocity?: number }): CompositeAnimation;
  decay(value: AnimatedValue, config?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): CompositeAnimation;
  sequence(list: CompositeAnimation[]): CompositeAnimation;
  parallel(list: CompositeAnimation[], config?: { stopTogether?: boolean }): CompositeAnimation;
  stagger(ms: number, list: CompositeAnimation[], config?: { from?: "first" | "last" | "center" | number }): CompositeAnimation;
  delay(ms: number): CompositeAnimation;
  loop(animation: CompositeAnimation, config?: { iterations?: number; resetBeforeIteration?: boolean }): CompositeAnimation;
  keyframes(value: AnimatedValue, config: KeyframeTiming & { keyframes: KeyframeValue[] }): CompositeAnimation;
  add(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  subtract(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  multiply(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  divide(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  modulo(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  diffClamp(a: AnimatedNode, min: number, max: number, options?: { snap?: boolean }): AnimatedNode;
  sampleSpring(config: SpringConfig & { from?: number }, fps?: number): number[];
  sampleDecay(config: DecayConfig & { from?: number }, fps?: number): number[];
};

interface CompositeAnimation {
  start(callback?: (result: { finished: boolean }) => void): Promise<{ finished: boolean }>;
  stop(): void;
  reset(): void;
}

Animated 以多個步驟(timing、spring、decay、sequence、parallel 等)組成動畫。在呼叫 start() 之前不會執行任何動作;呼叫後,整個樹會以原生方式執行,每個步驟都會在上一個步驟結束的一刻於 UI 執行緒上開始(沒有影格間隙,步驟之間也不需要回到應用程式的程式碼)。Rust 以 Animation 建立相同的樹。

函式 Rust 說明
timing、spring、decay Animation::timing(&value, to, Timing)、Animation::spring(&value, to, Spring)、Animation::decay(&value, velocity, deceleration) 對單一數值的單一動畫;選項和預設值與 AnimatedValue 方法相同,目標則以 toValue 指定。
sequence(list) Animation::sequence(list) 逐個依序執行各動畫。
parallel(list, { stopTogether }) Animation::parallel(list).stop_together(…) 同時執行各動畫。設定 stopTogether(預設為 true)後,停止其中一個即會停止全部。
stagger(ms, list, { from }) Animation::stagger(ms, list)、Animation::stagger_from(ms, StaggerFrom::Center, list) 以並行方式執行各動畫,每個動畫在上一個動畫開始 ms 後開始,並由 from 起計:"first"(預設)、"last"、"center"(中間的動畫立即開始,其餘由它向外依次展開;數量為偶數時,兩個中間項目都會立即開始)或索引(StaggerFrom::First、Last、Center、Index(i))。
keyframes(value, { keyframes, ...timing }) Animation::keyframes(&value, frames, KeyframeTiming) 透過關鍵影格播放一個值。
delay(ms) Animation::delay(ms) 等待;在 sequence 內很有用。
loop(animation, { iterations, resetBeforeIteration }) animation.looped(iterations).reset_before_iteration(…) 將 animation 重複 iterations 次(預設為無限次),每次執行前會將其數值還原,除非 resetBeforeIteration 為 false。
add、subtract、multiply、divide、modulo、diffClamp 節點方法 與 AnimatedNode 的方法相同,以函式形式提供。
sampleSpring、sampleDecay nsh_core::element::sample_spring、sample_decay 彈簧或衰減動畫每影格一個的數值(見 Easing)。

CompositeAnimation:

方法 Rust(Animation) 說明
start(callback?) start(),一個可供 await 的 AnimationEnd (再次)執行。整個樹只會完成一次並呼叫一次 callback,帶有 { finished }:表示是否已播放完畢而非被中止。
stop() stop() 在當前位置停止。
reset() reset() 停止並將其數值還原到開始時的狀態。
let ys: Vec<AnimatedValue> = (0..4).map(|_| animated_value(0.0)).collect();
let hop = Animation::stagger(
    80.0,
    ys.iter().map(|y| {
        Animation::sequence([
            Animation::timing(y, -36.0, Timing::new(220.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
            Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
        ])
    }),
);
let run = hop.start();
spawn(async move { log::info!("{}", if run.await { "done" } else { "stopped" }) });

#Easing

type Easing =
  | "linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
  | [number, number, number, number]   // cubic Bézier x1, y1, x2, y2
  | EasingCurve
  | EasingSteps;                     // Easing.steps(…)

const Easing: {
  linear: Easing;
  ease: Easing;
  bezier(x1: number, y1: number, x2: number, y2: number): Easing;
  steps(count: number, position?: "jump-start" | "jump-end" | "jump-none" | "jump-both" | "start" | "end"): EasingSteps;
  in(e: Easing): Easing;
  out(e: Easing): Easing;
  inOut(e: Easing): Easing;
  sample(easing: Easing, count: number): number[];
  quad: EasingCurve;
  cubic: EasingCurve;
  poly(n?: number): EasingCurve;
  sin: EasingCurve;
  circle: EasingCurve;
  exp: EasingCurve;
  elastic(bounciness?: number): EasingCurve;
  back(s?: number): EasingCurve;
  bounce: EasingCurve;
};

緩動是資料,而不是應用程式的函式:執行階段會以原生方式計算它。在 Rust 中,它是列舉 Easing:Linear、EaseIn、EaseOut、EaseInOut、Bezier(x1, y1, x2, y2)、Steps(count, StepPosition) 和 Curve(curve, mode),另有 Curve(Quad、Cubic、Poly(n)、Sin、Circle、Exp、Elastic(bounciness)、Back(s)、Bounce)和 EaseMode(In、Out、InOut)。若要繪製緩動曲線,Easing.sample(easing, count)(Rust:nsh_core::element::sample_easing)會給出由 0 至 1 之間 count 個等距點上的數值;Animated.sampleSpring 和 Animated.sampleDecay(sample_spring、sample_decay)會給出彈簧或衰減動畫每影格一個的數值(按 fps,預設為 60),由 from(0)起直至結束。這三者都來自動畫所執行的原生程式碼。在 TSX 中凡接受 Easing 的地方,你都可以傳入名稱("easeOut")、以四個數字表示的三次貝茲曲線,或 Easing 物件中的曲線。

曲線 Rust 形狀
quad Curve::Quad t²
cubic Curve::Cubic t³
poly(n) Curve::Poly(n) tⁿ(n 預設為 2)
sin Curve::Sin 正弦
circle Curve::Circle 圓形
exp Curve::Exp 指數
elastic(bounciness) Curve::Elastic(bounciness) 彈性過衝(bounciness 預設為 1)
back(s) Curve::Back(s) 先向後拉再前進;s 為過衝量(預設 1.70158)
bounce Curve::Bounce 在終點彈跳

Easing.steps(count, position) 即 CSS 的 steps():數值會在 count 個層級之間跳躍,而不是連續移動(例如逐格播放的精靈圖、滴答轉動的指針)。position 指定跳躍發生的位置:"jump-end"(預設,亦可寫作 "end")、"jump-start"("start")、"jump-none" 或 "jump-both";Rust:Easing::Steps(count, StepPosition::JumpEnd)、JumpStart、JumpNone、JumpBoth。

這些曲線都是「in」形式(起步較慢)。Easing.out(curve) 會將曲線反轉,Easing.inOut(curve) 會令曲線對稱,Easing.in(curve) 則保持原樣;在 Rust 中,這是 Easing::Curve 的 EaseMode。

let _ = value.timing(1.0, 600.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
let _ = value.timing_with(1.0, Timing { easing: Easing::Bezier(0.2, 0.0, 0.0, 1.0), ..Timing::default() });
let _ = value.timing_with(1.0, Timing { easing: Easing::EaseOut, ..Timing::default() });

#LayoutAnimation

const LayoutAnimation: {
  configureNext(config: LayoutAnimationConfig, onEnd?: () => void): void;
  create(duration: number, type?: LayoutAnimationType, property?: LayoutAnimationProperty): LayoutAnimationConfig;
  Types: { easeInEaseOut; spring; linear; easeIn; easeOut; keyboard };
  Properties: { opacity; scaleXY };
  Presets: { easeInEaseOut: LayoutAnimationConfig; linear: LayoutAnimationConfig; spring: LayoutAnimationConfig };
  easeInEaseOut(onEnd?: () => void): void;
  linear(onEnd?: () => void): void;
  spring(onEnd?: () => void): void;
};

interface LayoutAnimationConfig {
  duration: number;                // ms
  create?: LayoutAnimationAnimation;    // views inserted
  update?: LayoutAnimationAnimation;    // views moved or resized
  delete?: LayoutAnimationAnimation;    // views removed
}

interface LayoutAnimationAnimation {
  type?: "easeInEaseOut" | "spring" | "linear" | "easeIn" | "easeOut" | "keyboard";
  property?: "opacity" | "scaleXY" | "scale";
  duration?: number;
  delay?: number;
  springDamping?: number;          // 0..1, default 0.7
}

LayoutAnimation.configureNext(config) 會以原生方式為下一次提交中的版面變更加上動畫:移動或改變大小的視圖會平滑過渡到新的框架,新視圖會出現(按 property 淡入或縮放進場),被移除的視圖則會消失。請在改變版面的狀態變更之前立即呼叫它。Rust:animate_layout(LayoutAnimation),可使用 nsh_core::element::LayoutAnimation 的預設,或其 create、update 和 delete 轉場。

成員 Rust 說明
configureNext(config, onEnd?) animate_layout(config) 為下一次提交的變更加上動畫;完成後會執行 onEnd。
create(duration, type?, property?) LayoutAnimation::all(LayoutTransition::new(curve, duration)) 三個階段使用同一條曲線的設定。
Presets.easeInEaseOut LayoutAnimation::EASE_IN_EASE_OUT 300 毫秒,視圖淡入淡出。
Presets.linear LayoutAnimation::LINEAR 500 毫秒,速度恆定。
Presets.spring LayoutAnimation::SPRING 700 毫秒;框架以彈簧效果移動(阻尼 0.4),視圖以線性方式淡入淡出。
easeInEaseOut(onEnd?)、linear(onEnd?)、spring(onEnd?) animate_layout(LayoutAnimation::…) configureNext(Presets.…) 的簡寫。
Types、Properties LayoutCurve、LayoutProperty 以常數形式提供的名稱。
let open = signal(false);
let toggle = move || {
    animate_layout(LayoutAnimation::SPRING);
    open.update(|o| *o = !*o);
};

view()
    .child(text("Details").on_press(toggle))
    .child(when(move || open.get(), || text("Shown with a spring.")))

#Device

Device API 讓應用程式得知裝置及其環境的資訊。Rust 程式碼呼叫 nsh_core::prelude 的函式(window()、dark()、font_scale()、locale()、lifecycle()、os()、idiom()、pixel_ratio()、set_color_scheme() 等);TSX 則從 nsh 匯入。視窗大小、色彩配置、字體縮放、像素比例、語言地區和應用程式狀態都是響應式的:在綁定(Rust 中傳給元素的閉包,TSX 中的 JSX)、computed 或 effect 中讀取它們,當平台通報變更(旋轉、調整視窗大小、切換到深色模式、更改文字大小或語言、移到另一種密度的螢幕、離開應用程式後再返回)時,讀取它們的內容就會隨之更新。

use nsh_core::{
    platform::{Appearance, Idiom, Lifecycle, Os},
    prelude::*, // window, dark, font_scale, locale, lifecycle, os, idiom, pixel_ratio, set_color_scheme, ...
};

指南請參閱裝置與環境。

#windowSize

const windowSize: Accessor<Size>;

介面的大小,以點(point)為單位:即視窗,或嵌入的 NshAppView / NshView。響應式。Rust:window() -> Size<Points>。

let wide = move || window().width >= px(600.0);

view().style_when(wide, |s| s.flex_row())

#colorScheme

const colorScheme: Accessor<"light" | "dark">;

所顯示的外觀:除非應用程式自行設定(nsh.json "appearance"、setColorScheme),否則為系統的外觀。響應式。Rust:dark() -> bool;主題顏色(AdaptiveColor)和 .dark(…) 樣式變體無需讀取它也會跟隨。Tailwind 的 dark: 變體也會跟隨它(見 Tailwind)。

view().flex_1().bg(move || if dark() { black() } else { white() })

#fontScale

const fontScale: Accessor<number>;

使用者偏好的文字大小相對於預設值的比例(1 為預設大小;iOS 上為 Dynamic Type,Android 上為字體大小),或由嵌入的應用程式改為設定的文字大小。響應式。Rust:font_scale() -> f32。文字已會隨之縮放,除非設定了 allowFontScaling={false};當其他尺寸也應跟隨時(例如文字旁的圖示),請讀取它。

#locale

const locale: Accessor<string>;

使用者的語言地區,以 BCP 47 標籤表示("en-US"、"zh-Hans-CN"),或由嵌入的應用程式改為設定的語言地區。響應式。Rust:locale() -> Locale(locale().as_str() 為標籤)。Intl 預設會按它格式化;請讀取它來選擇你自己翻譯的字串(裝置與環境)。

#lifecycle

const lifecycle: Accessor<"active" | "inactive" | "background">;

應用程式是否正在使用,以 React Native 的 AppState 術語表示:"active" 表示位於前景並接受輸入,"inactive" 表示可見但沒有焦點(其他應用程式或視窗取得了焦點,或系統在其上方顯示內容,例如應用程式切換器或來電),"background" 表示不可見(在背景、已最小化、隱藏的瀏覽器分頁)。顯示在原生應用程式內的應用程式,會跟隨該原生應用程式的狀態。響應式。Rust:lifecycle() -> Lifecycle(Lifecycle::Active、Inactive、Background)。

在 effect 中讀取它,即可暫停無人看見的工作,並在應用程式返回時恢復:

effect(move || {
    if lifecycle() == Lifecycle::Background {
        feed.pause();
    } else {
        feed.resume();
    }
});

#setColorScheme

function setColorScheme(scheme: "light" | "dark" | "system"): void;

無論系統的外觀為何,立即以 scheme 顯示應用程式:colorScheme()、dark: 類別、元件的主題和平台的控制項(警示、狀態列的預設樣式)都會跟隨它;"system" 則會再次跟隨系統。nsh.json "appearance" 會在一開始就設定它。Rust:set_color_scheme(Appearance::Dark)(Appearance::Light、Appearance::System)。

#platform

const platform: Platform;

interface Platform {
  readonly os: "ios" | "android" | "macos" | "windows" | "linux" | "web";
  readonly version: string;
  readonly idiom: "phone" | "pad" | "mac" | "tv" | "desktop";
  readonly desktop: boolean;
  readonly catalyst: boolean;
  select<T>(spec: PlatformSelectSpec<T>): T | undefined;
}

應用程式執行所在的平台,執行期間保持不變。

成員 Rust 說明
os os() -> Os(os().name() 是 TS 字串) 作業系統。iOS、iOS 模擬器及 Mac Catalyst 回報 "ios",瀏覽器回報 "web"。
version os_version() -> (u16, u16) iOS 和桌面平台:作業系統版本("17.5");Android:API level("35");網頁:"0.0"。Rust 提供主版本和次版本,在 Android 上則為 API level 和 0。
idiom idiom() -> Idiom 裝置類型:只有在 desktop 為 true 時才是 "desktop";其他情況為 "phone"、"pad"(iPad、Android 平板電腦、iPad idiom 的 Mac Catalyst)、"mac"(UIKit Mac idiom 的 Mac Catalyst,屬行動 idiom)或 "tv"(Apple TV、Android TV)。
desktop is_desktop() -> bool 套用桌面行為:macOS、Windows、Linux,以及配備滑鼠和寬視窗的瀏覽器。desktop={{…}} props、select({ desktop }) 和 idiom "desktop" 都以此為準。Mac Catalyst 執行 iOS backend,因此屬於行動平台:ios={{…}} props 在該處適用。nsh/desktop 的 isAvailable 是範圍較窄的檢查,針對其視窗和選單 API,而這些 API 只有 macOS、Windows 和 Linux 才有。
catalyst is_catalyst() -> bool 應用程式在 Mac Catalyst 下執行,屬 Mac 或 iPad idiom(os 為 "ios")。
select(spec) match os() { … } 此平台對應的值(見下文)。
let columns = if is_desktop() { 3 } else { 1 };

#platform.select

interface PlatformSelectSpec<T> {
  ios?: T;
  android?: T;
  macos?: T;
  windows?: T;
  linux?: T;
  web?: T;
  mobile?: T;   // platform.desktop false: iOS (Mac Catalyst included), Android, phone and tablet browsers
  desktop?: T;  // platform.desktop true: macOS, Windows, Linux, desktop browsers
  native?: T;   // every platform but the web
  default?: T;
}

傳回目前作業系統(ios、android、web 等)對應的值,否則依 platform.desktop 傳回 desktop 或 mobile,再否則傳回 native(除網頁外的所有平台),再否則傳回 default,最後傳回 undefined。Rust 程式碼則對 os() 進行比對(對 mobile / desktop 則使用 is_desktop())。

use nsh_core::platform::Os;

let card = view().p_4();
let card = match os() {
    Os::Ios => card.shadow([BoxShadow {
        offset_y: px(2.0),
        blur: px(8.0),
        color: rgba(0x00000026).into(),
        ..BoxShadow::default()
    }]),
    Os::Android => card.elevation(px(4.0)),
    _ => card,
};

#pixelRatio

const pixelRatio: Accessor<number>;

每個點(point)對應的裝置像素數(例如大多數 iPhone 為 3)。具反應性:當視窗移到另一個密度不同的螢幕(在 Mac Catalyst、桌面平台或外接顯示器上),以及 Android 的顯示大小設定改變密度時,它都會隨之改變。Rust:pixel_ratio() -> f32,以及用於以整數裝置像素表示長度的 round_to_nearest_pixel(points);在 TSX 中該長度為 Math.round(x * pixelRatio()) / pixelRatio()。

let hairline = move || px(1.0 / pixel_ratio());

#openUrl

function openUrl(url: string): Promise<OpenUrlResult>;

interface OpenUrlResult {
  opened: boolean;
}

在應用程式以外開啟 url:網頁會在瀏覽器開啟,mailto: 連結會在郵件應用程式開啟,tel: 連結會在電話應用程式開啟,其他應用程式登記的任何 scheme 也一樣(maps:、宿主應用程式自己的 lb:// 連結)。它會以 { opened } resolve,表示是否有東西開啟了它:當沒有應用程式處理該 scheme 時為 false。Rust:nsh_core::prelude 的 open_url(url),即在 task 中 await(或直接丟棄)的結果,同樣是 bool。

button()
    .child(text("Contact us"))
    .on_press(|| {
        spawn(async {
            if !open_url("mailto:help@example.com").await {
                log::warn!("no mail app");
            }
        });
    })
  • URL 必須是絕對 URL(帶有 scheme)。會執行程式碼或存取應用程式自身資料的 URL,即 javascript:、data:、blob:、file:、about: 和 asset:,以及相對位址,都會令 promise 以 TypeError reject。openUrl 從不丟出例外:所有失敗都會 reject。
  • 聲明了 capabilities 的應用程式需要 openUrl:"openUrl": true 表示所有 scheme,"openUrl": { "schemes": ["https", "mailto"] } 表示所列出的 scheme;其他 URL 會以 Error reject,其 code 為 "ERR_ACCESS_DENIED",permission 為 "openUrl",resource 為該 URL(見沙盒與 capabilities)。在 Rust 中,open_url 會對這些 URL 回應 false 並記錄原因。
  • 嵌入式應用程式會先看到該 URL,並可改為透過自己的 router 處理(Swift NshAppView.onOpenURL、Java NshView.setOpenUrlHandler、Rust AppView::host().handle_open_url;見 iOS 和 Android)。此時 opened 為 true。
  • 各平台使用自己的開啟方式:iOS 使用 UIApplication.open(嵌入時使用宿主應用程式的 application),Android 使用 ACTION_VIEW intent,macOS 使用 NSWorkspace,Windows 使用 ShellExecuteW,Linux 使用 GtkUriLauncher,GPUI 使用 open_url,網頁則開啟新分頁。nsh test 只會記錄 URL 而不會開啟。
  • TextView 以 links="open" 用這種方式開啟其連結。

#版面方向

版面方向跟隨使用者的語言:使用從右至左的語言(阿拉伯語、希伯來語等)時,應用程式會由右至左排版,列(row)從右邊開始排列,而 left / right 樣式屬性(marginLeft、paddingRight 等)則表示起始和結束。變更會立即生效,無需重新啟動。

檢視的 direction 樣式("ltr"、"rtl" 或預設的 "inherit")設定其自身子樹的方向;在 Rust 中為 .direction(LayoutDirection::..)。

view().direction(LayoutDirection::Rtl).flex_row().gap_2().child(text("First")).child(text("Second"))

#相關內容

  • 安全區域 insets(Rust safe_area();TSX safeArea()、safeAreaInsets())和軟鍵盤(Rust keyboard_height();TSX keyboardFrame())請見安全區域與鍵盤。
  • 螢幕閱讀器和減少動態效果的設定請見無障礙。

#PanResponder

Pan responder 是建基於 gesture responder 系統的拖曳手勢:元素會認領一個手勢的觸控,然後獨自接收這些觸控及手勢狀態,手勢狀態會為你追蹤觸控點的質心、移動距離和速度。Rust 程式碼以 .claim_touches(TouchClaim::…) 認領觸控,並以 .on_responder_* 監聽器接收,其 ResponderEvent 帶有 PanGesture,全部位於 nsh_core::prelude;TSX 則從 nsh 匯入 PanResponder,其 onPanResponder… 回呼會接收觸控事件和手勢狀態。

use nsh_core::prelude::*; // TouchClaim, ClaimRequest, ResponderEvent, PanGesture

#PanResponder.create

const PanResponder: { create(config: PanResponderCallbacks): PanResponderInstance };

interface PanResponderInstance {
  panHandlers: TouchProps;
}

建立一個 pan responder。把它的 panHandlers 展開到應接收手勢的檢視上。在 Rust 中,由檢視自行認領觸控並監聽:

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let pos = signal((0.0_f32, 0.0_f32));
    let start = stored((0.0_f32, 0.0_f32));
    view().flex_1().child(
        view()
            .size(px(64.0))
            .rounded(px(32.0))
            .bg(rgb(0x6366f1))
            .style_with(move |s| {
                let (x, y) = pos.get();
                s.translate_x(px(x)).translate_y(px(y))
            })
            .claim_touches(TouchClaim::on_start())
            .on_responder_grant(move |_: &ResponderEvent| {
                start.replace(pos.get_untracked());
            })
            .on_responder_move(move |e: &ResponderEvent| {
                let (x, y) = start.get();
                pos.set((x + e.gesture.delta.x.0, y + e.gesture.delta.y.0));
            })
            .on_responder_release(move |e: &ResponderEvent| {
                log::info!("released at {:.0} pt/s", e.gesture.vx);
            }),
    )
}

#回呼

每個回呼都會接收觸控事件(GestureResponderEvent,來自 nsh)和手勢狀態。在 Rust 中,認領以 .ask(|request: &ClaimRequest| …) 作決定,呼叫 request.claim() 以取得觸控(沒有 ask 時則一律取得),而每個監聽器都會收到一個 ResponderEvent,其 gesture 就是該狀態。

type PanHandler<R = void> = (e: GestureResponderEvent, gestureState: PanResponderGestureState) => R;
回呼 Rust 傳回值 呼叫時機
onStartShouldSetPanResponder .claim_touches(TouchClaim::on_start()) boolean 手指按下:傳回 true 即成為 responder。由被觸碰的檢視向外詢問。
onStartShouldSetPanResponderCapture TouchClaim::on_start().capture() boolean 同上,但在冒泡階段的詢問之前由外向內詢問(父檢視從子檢視手中取得手勢)。
onMoveShouldSetPanResponder TouchClaim::on_move().ask(…) boolean 手指移動:傳回 true 即成為 responder(例如只在 Math.abs(g.dx) > 10 之後)。
onMoveShouldSetPanResponderCapture TouchClaim::on_move().capture() boolean 同上,屬捕捉階段。
onPanResponderGrant .on_responder_grant(…) 此檢視現在成為 responder:手勢開始。
onPanResponderReject .on_responder_reject(…) 另一個檢視是 responder,且不肯放手。
onPanResponderStart .on_responder_start(…) 在回應期間有手指按下。
onPanResponderMove .on_responder_move(…) 手指移動了。
onPanResponderEnd .on_responder_end(…) 在回應期間有手指離開。
onPanResponderRelease .on_responder_release(…) 最後一隻手指離開:手勢結束。
onPanResponderTerminate .on_responder_terminate(…) 另一個檢視(或原生手勢,例如捲動檢視)取得了觸控。
onPanResponderTerminationRequest .keep_touches(true) 保留觸控 boolean 另一個檢視想取得觸控:傳回 false 以保留(預設為 true)。
onShouldBlockNativeResponder TouchClaim::block_native(…) boolean 回應期間是否阻止原生捲動檢視取得觸控(預設為 true)。

#PanResponderGestureState

interface PanResponderGestureState {
  stateID: number;
  moveX: number;               // latest centroid of the touches (surface points)
  moveY: number;
  x0: number;                  // the centroid when the responder was granted
  y0: number;
  dx: number;                  // distance moved since the grant
  dy: number;
  vx: number;                  // velocity, points per second
  vy: number;
  numberActiveTouches: number;
}

Rust:PanGesture,座標點為 Point<Points>。

欄位 Rust 說明
stateID 識別該手勢。
moveX、moveY position 觸控點質心的最新位置,以 surface 的點(point)為單位。
x0、y0 origin 取得 responder 時質心所在的位置。
dx、dy delta 自取得 responder 以來移動的距離。
vx、vy vx、vy 速度,以每秒點數(points per second)為單位。
numberActiveTouches active_touches 按下的手指數目。

#在捲動檢視內

當 pan responder 是 responder 時,其周圍的原生捲動檢視不會捲動(onShouldBlockNativeResponder 預設為 true)。若要讓水平滑動與垂直捲動並存,請只在水平移動時才成為 responder:

view()
    .claim_touches(TouchClaim::on_move().ask(|request: &ClaimRequest| {
        let d = request.event.gesture.delta;
        if d.x.0.abs() > 10.0 && d.x.0.abs() > d.y.0.abs() {
            request.claim();
        }
    }))
    .on_responder_move(move |e: &ResponderEvent| offset.set(e.gesture.delta.x.0))
    .on_responder_release(move |e: &ResponderEvent| {
        if e.gesture.delta.x.0.abs() > 120.0 { remove() } else { offset.set(0.0) }
    })
    .on_responder_terminate(move |_: &ResponderEvent| offset.set(0.0))

對於平台本身可識別的拖曳,TouchClaim::on_pan(PanActivation::horizontal(10.0, 10.0)) 會在手指移動時於主執行緒上作決定。底層的 responder props(onStartShouldSetResponder、onResponderGrant、onTouchStart 等)也可以直接用在任何元素上;請見元素。

#Clipboard

Clipboard API 可讀寫系統剪貼簿:所有平台均支援文字(iOS 為 UIPasteboard,Android 為 ClipboardManager,桌面平台為 NSPasteboard、GDK 和 Win32 剪貼簿,網頁為 navigator.clipboard);iOS、桌面平台和網頁支援圖片;桌面平台和 Mac Catalyst 則支援在檔案管理器中複製的檔案。Rust 程式碼呼叫 nsh_core::prelude 的函式(set_clipboard、clipboard_string 等);TSX 則從 nsh 匯入 Clipboard。

use nsh_core::prelude::*; // set_clipboard, clipboard_string, clipboard_image, ...
use nsh_core::element::ClipboardImage;
const Clipboard: {
  writeText(text: string): Promise<void>;
  readText(): Promise<string>;
  hasText(): Promise<boolean>;
  writeImage(src: string): Promise<void>;
  readImage(): Promise<ClipboardImage | null>;
  hasImage(): Promise<boolean>;
  readFiles(): Promise<string[]>;
};

interface ClipboardImage {
  url: string; // a URL that Image shows and fetch reads
  size: PixelSize; // in pixels
}
TS Rust(nsh_core::prelude)
await Clipboard.writeText(text) set_clipboard(text)
await Clipboard.readText() clipboard_string().await -> String
await Clipboard.hasText() clipboard_has_string().await -> bool
await Clipboard.writeImage(src) set_clipboard_image(src)
await Clipboard.readImage() clipboard_image().await -> Option<ClipboardImage>
await Clipboard.hasImage() clipboard_has_image().await -> bool
await Clipboard.readFiles() clipboard_files().await -> Vec<String>

Rust 的讀取函式會傳回要在 task 中 await 的結果(spawn,或 async 模組函式);ClipboardImage 為 nsh_core::element::ClipboardImage,包含 url 和 size(一個 Size<DevicePixels>)。

iOS、桌面平台和網頁可透過剪貼簿處理圖片。在 Android 上,剪貼簿只承載文字:writeImage 和 readImage 會以 Error reject,其 code 為 "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM",而 hasImage 會 resolve false。在 Rust 中,set_clipboard_image 會令該處的剪貼簿維持原樣,而 clipboard_image 會回應 None。

使用者也可以自行從可選取文字複製;Clipboard API 則用於從程式碼進行複製,例如「複製」按鈕。

#Clipboard.writeText

writeText(text: string): Promise<void>;

把 text 放到剪貼簿,取代原有內容。Rust:set_clipboard(text)。

use nsh_core::prelude::*;

fn copy_button(code: Signal<String>) -> impl IntoElement {
    let copied = signal(false);
    view()
        .role(AccessibilityRole::Button)
        .on_press(move || {
            set_clipboard(code.get());
            copied.set(true);
        })
        .child(text(move || if copied.get() { "Copied" } else { "Copy" }))
}

#Clipboard.readText

readText(): Promise<string>;

以剪貼簿的文字 resolve,如果剪貼簿沒有文字則為 ""。Rust:clipboard_string()。在 iOS 上,讀取剪貼簿可能會顯示系統的貼上通知(「App pasted from …」),而在 Android 10 及更新版本上,只有位於前景的應用程式才能讀取。請在使用者要求貼上時才讀取,不要在每個畫面都讀取。

spawn(async move {
    let pasted = clipboard_string().await;
    if !pasted.is_empty() {
        code.set(pasted.trim().to_string());
    }
});

#Clipboard.hasText

hasText(): Promise<boolean>;

以剪貼簿是否有文字 resolve,而不會讀取內容:在 iOS 上不會出現貼上通知。Rust:clipboard_has_string()。可用它來決定是否顯示「貼上」按鈕。在網頁上,瀏覽器只能透過讀取來判斷,因此可能會向使用者要求權限。

let can_paste = signal(false);
spawn(async move { can_paste.set(clipboard_has_string().await) });

#Clipboard.readImage

readImage(): Promise<ClipboardImage | null>;

以剪貼簿的圖片 resolve,如果剪貼簿沒有圖片則為 null:例如貼上的螢幕截圖,或在瀏覽器或圖片編輯器中複製的圖片。Rust:clipboard_image(),沒有圖片時為 None。url 是該圖片的 URL(PNG,或剪貼簿本身的 JPEG、GIF、WebP 或 BMP),可由 Image 以其 src 顯示,亦可由 fetch 讀取:fetch(url) 會提供其位元組,用於上傳。size(width 和 height)是解碼後以像素為單位的尺寸。與 readText 一樣,請在使用者要求貼上時才讀取。最大 48 MB(編碼後)的圖片會被讀取,以確保圖片能放進應用程式的記憶體;更大的圖片會以 Error reject,其 code 為 "ERR_NSH_QUOTA_EXCEEDED"(其訊息會說明圖片大小和上限),而 hasImage 會 resolve true。Rust:clipboard_image() 會對此回應 None。

use base64::Engine as _;
use nsh_core::{element::ClipboardImage, prelude::*};

fn attachments() -> impl IntoElement {
    let attachments = signal(Vec::<ClipboardImage>::new());
    let paste = move || {
        spawn(async move {
            if let Some(image) = clipboard_image().await {
                attachments.update(|list| list.push(image));
            }
        });
    };
    view()
        .flex_row()
        .gap_2()
        .child(text("Paste").on_press(paste))
        .child(each(
            attachments,
            |shot: &ClipboardImage| shot.url.clone(),
            |shot, _| image(move || shot.with(|s| s.url.clone())).size(px(64.0)).rounded(px(8.0)),
        ))
}

async fn upload(image: &ClipboardImage) -> Result<HttpResponse, String> {
    let (_, data) = image.url.split_once(',').ok_or("not a data: URL")?;
    let bytes = base64::engine::general_purpose::STANDARD
        .decode(data)
        .map_err(|e| e.to_string())?;
    let request = HttpRequest::post("https://example.com/upload", bytes).header("Content-Type", "image/png");
    fetch(request).await
}

在聊天類應用程式中,使用者會把螢幕截圖貼到輸入框,這類應用程式會在輸入框取得焦點時綁定貼上快速鍵,並在有圖片時取用:

use nsh_core::keymap::When;

on_key_with("mod+v", KeyOptions { when: When::Always, ..KeyOptions::default() }, move |_| {
    spawn(async move {
        if clipboard_has_image().await {
            paste().await;
        } else {
            insert_text(&clipboard_string().await);
        }
    });
});

#Clipboard.hasImage

hasImage(): Promise<boolean>;

以剪貼簿是否有圖片 resolve,而不會讀取內容:在 iOS 上不會出現貼上通知。Rust: clipboard_has_image()。在 Android 上會 resolve false。

#Clipboard.writeImage

writeImage(src: string): Promise<void>;

把 src 所指的圖片放到剪貼簿,取代原有內容;可以是 base64 data: URL,或 readImage 提供的 url。 Rust:set_clipboard_image(src)。在 Windows 上它接受 PNG,即瀏覽器、Office 和「剪取工具」讀取的格式。在 Android 上會以 "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" reject。

set_clipboard_image(image.url.clone());

#Clipboard.readFiles

readFiles(): Promise<string[]>;

以在檔案管理器(Finder、Explorer、Files)中複製的檔案和資料夾的 file: URL resolve,按其 順序排列,或為 []:與檔案拖放提供的 URL 相同。Rust:clipboard_files()。 解析 URL 即可取得其路徑。

let paths: Vec<std::path::PathBuf> = clipboard_files()
    .await
    .iter()
    .filter_map(|file| url::Url::parse(file).ok()?.to_file_path().ok())
    .collect();

#Picker

Picker API 會顯示系統的相片選擇器,並把使用者所選的圖片交給應用程式:iOS 上是 Photos picker(PHPickerViewController),Android 上是 Photo Picker,桌面平台上是開啟面板或檔案對話框,瀏覽器中則是 file input。在 iOS 和 Android 上,選擇器在應用程式以外運行,只交出使用者所選的內容,因此不需要相片庫權限。每張選取的圖片會以 File(TSX)或 PickedFile(Rust)的形式送達,其位元組保留在原生端:以 URL.createObjectURL(file) 在 Image 中顯示,以 fetch 和 FormData 上傳。Rust 程式碼呼叫 pick_images(位於 nsh_core::prelude);TSX 從 nsh 匯入 Picker。

use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*; // pick_images
const Picker: {
  pickImages(options?: PickImagesOptions): Promise<PickedImage[]>;
};

interface PickImagesOptions {
  multiple?: boolean; // let the user pick several (false: one)
  limit?: number; // at most this many when multiple (up to 100)
  maxDimension?: number; // scale images whose longer side is longer, in pixels
  quality?: number; // JPEG quality from 0 to 1 (0.9)
  format?: "compatible" | "current"; // "compatible": HEIC and the like as JPEG
}

interface PickedImage {
  file: File; // name, type, size in bytes and the bytes
  size: PixelSize; // upright
}
TS Rust
await Picker.pickImages(options) pick_images(request).await -> Vec<PickedFile>
multiple, limit PickRequest::new(multiple, limit)(limit:0 代表最多 100 張)
maxDimension request.max_dimension = Some(DevicePixels(2048))
quality request.quality = Some(Percentage(0.8))
format request.format = PickFormat::Current
image.file.name, .type, .size file.name, file.mime, file.data.len()
image.size file.size(Size<DevicePixels>)

#Picker.pickImages

pickImages(options?: PickImagesOptions): Promise<PickedImage[]>;

顯示選擇器,並在使用者選取後,按選取順序以所選圖片 resolve;若使用者取消,則以 [] resolve。Rust:pick_images(request),是在任務中 await 的結果(spawn,或 async 模組函式)。pickImages 永不拋出例外:超出範圍的選項(limit: 0,即不在 0 至 1 之間的 quality)會以 RangeError reject promise,而應用程式的 capabilities 未授權的選取會以 ERR_ACCESS_DENIED reject(見下文)。取消是使用者的回應,並非失敗:會以 [] resolve。

圖片送達時已可直接傳送:

  • 所有瀏覽器和伺服器都支援的格式。 JPEG、PNG、GIF 和 WebP 會保持原樣;以其他格式儲存的相片(iPhone 上的 HEIC、HEIF、TIFF)會轉為 JPEG。format: "current" 則保留儲存時的格式。
  • 在原生端縮小。 使用 maxDimension 時,較長邊超出該值的圖片會縮小至符合該值、轉正方向,然後重新編碼:PNG 仍為 PNG,其餘轉為品質 quality 的 JPEG。手機相片有 12 至 48 百萬像素;上傳前縮小至 2048 像素,可使檔案只有數百 KB。quality 同樣會重新編碼 JPEG。
  • 顯示的內容。 size 是圖片顯示時的像素尺寸,已套用相機方向。NSH 重新編碼的圖片不帶任何中繼資料(包括位置);保持原樣的圖片則保留其自身的中繼資料。

在 Windows、Linux 和 GPUI 視窗中,對話框會提供請求所接受的格式(使用 "compatible" 時:JPEG、PNG、GIF 和 WebP),檔案以其儲存時的原樣和原始尺寸送達。

聊天輸入框附加相片、在撰寫訊息期間顯示相片,並在訊息送出時上傳:

use nsh_core::geometry::{DevicePixels, Percentage};
use nsh_core::picker::{PickRequest, PickedFile};
use nsh_core::prelude::*;

fn attach_button(attached: Signal<Option<PickedFile>>) -> impl IntoElement {
    view()
        .role(AccessibilityRole::Button)
        .accessibility_label("Choose a photo")
        .on_press(move || {
            let mut request = PickRequest::new(false, 1);
            request.max_dimension = Some(DevicePixels(2048));
            request.quality = Some(Percentage(0.85));
            let picked = pick_images(request);
            spawn(async move {
                if let Some(file) = picked.await.into_iter().next() {
                    attached.set(Some(file));
                }
            });
        })
        .child(text("Photo"))
}

async fn upload(file: &PickedFile) -> Result<HttpResponse, String> {
    let request = HttpRequest::post("https://example.com/upload", file.data.to_vec())
        .header("Content-Type", file.mime.as_str());
    fetch(request).await
}

一次選取多張相片:

let picked = pick_images(PickRequest::new(true, 9));
spawn(async move {
    let files = picked.await;
    photos.update(|list| list.extend(files));
});

#嵌入應用程式

嵌入 NSH 並自備選擇器(例如附有應用程式本身相簿的選擇器)的應用程式,可以自行回應選取:在 Rust 中,把 PickerBackend(nsh_core::picker)交給引擎(EngineConfig::picker,在 GPUI 和 GPUI Kit 應用程式中則為 AppView::set_picker),它會接收每次選取的 PickRequest 並回傳檔案,系統的選擇器則不會顯示。它回傳的檔案會與平台提供的一樣經過處理:按請求的上限裁剪,並從位元組中讀取其大小、類型和副檔名。

#測試

nsh test 會在 headless 執行中回應選取:app.onPick(handler) 回傳使用者所選的檔案({ name, type, data }),沒有 handler 時,選取會被取消。參閱 測試。

#Speech

Speech API 把使用者所說的話轉為文字(語音輸入):應用程式開始一個 session,平台聆聽麥克風,而逐字稿會隨內容增加而陸續送達。macOS 和 iOS 使用系統的 Speech framework 進行辨識;系統會向使用者詢問一次麥克風和語音辨識的權限。在其他平台上,session 會立即以錯誤 "unavailable" 結束,因此在顯示麥克風按鈕前,請先檢查 Speech.available()。Rust 程式碼呼叫 recognize_speech(位於 nsh_core::prelude);TSX 從 nsh 匯入 Speech。

use nsh_core::speech::{SpeechEvent, SpeechRequest};
const Speech: {
  available(): Promise<boolean>;
  start(options?: SpeechOptions): SpeechSession;
};

interface SpeechOptions {
  /** The language spoken, a BCP 47 tag ("en-US", "zh-CN"); the user's when left out. */
  lang?: string;
  /** Results while the user speaks (true, the default), or only the final one. */
  interim?: boolean;
}

interface SpeechSession {
  on(event: "start", listener: () => void): () => void;
  on(event: "result", listener: (result: SpeechResult) => void): () => void;
  on(event: "end", listener: (end: SpeechEnd) => void): () => void;
  /** The whole transcript so far. */
  readonly text: string;
  readonly ended: boolean;
  stop(): void;
  abort(): void;
}

interface SpeechResult { text: string; isFinal: boolean }
interface SpeechEnd { text: string; error?: SpeechErrorCode }

type SpeechErrorCode =
  | "not-allowed" | "no-speech" | "audio-capture" | "network"
  | "language-not-supported" | "unavailable" | "failed";
TSX Rust
await Speech.available() speech_available().await -> bool
Speech.start(options) recognize_speech(request) -> SpeechRecognition
session.on("result", ...) recognition.next().await -> Option<SpeechEvent>
session.stop() / abort() recognition.stop() / abort()(drop 時會中止)

#Speech.start

開始一個 session 並立即回傳。session 會按以下順序回報:麥克風開啟且辨識器開始聆聽時回報 start;逐字稿每次變動時回報 result(text 永遠是到目前為止的完整逐字稿,因此應用程式可直接原樣顯示);最後回報一次 end,之後不會再有任何事件。stop() 會停止聆聽,並讓辨識器完成已聽到的內容:先回報帶有 isFinal: true 的最後結果,然後是 end。abort() 會立即結束 session,不產生錯誤,並丟棄尚未回報的內容。同一時間只會運行一個 session:開始另一個 session 會中止正在運行的那個。

end 帶有最後的逐字稿,而當 session 失敗時帶有 error,其命名與 Web Speech API 一致:

error 情況
"not-allowed" 使用者或系統不允許使用麥克風或語音辨識。
"no-speech" 沒有說話。
"audio-capture" 無法使用麥克風。
"network" 辨識器需要網絡但無法連線。
"language-not-supported" 辨識器不認識 lang。
"unavailable" 此平台不辨識語音,或目前無法辨識。
"failed" 辨識器因其他原因失敗。

選項類型錯誤時,start 會拋出 TypeError;lang 不是語言標籤時,則會拋出 RangeError。

import { signal, Speech, type SpeechSession } from "nsh";

const transcript = signal("");
let session: SpeechSession | null = null;

function toggle() {
  if (session && !session.ended) {
    session.stop();
    return;
  }
  session = Speech.start({ lang: "en-US" });
  session.on("result", ({ text }) => transcript.set(text));
  session.on("end", ({ text, error }) => {
    if (error) console.warn("dictation ended:", error);
    else transcript.set(text);
  });
}
use nsh_core::prelude::*;
use nsh_core::speech::{SpeechEvent, SpeechRequest};

async fn dictate() -> String {
    let mut recognition = recognize_speech(SpeechRequest::default());
    let mut text = String::new();
    while let Some(event) = recognition.next().await {
        match event {
            SpeechEvent::Result { text: heard, .. } => text = heard,
            SpeechEvent::End { .. } => break,
            _ => {}
        }
    }
    text
}

#Speech.available

在 session 可以運行的地方 resolve 為 true:即 macOS 和 iOS,以及嵌入應用程式自行辨識語音的任何地方。

#Info.plist

在 macOS 和 iOS 上,系統會顯示應用程式需要麥克風和語音辨識的原因。當應用程式可使用語音時(其 capabilities 授予 speech,或沒有宣告任何權限),nsh package 和 nsh run 會把 NSMicrophoneUsageDescription 和 NSSpeechRecognitionUsageDescription 寫入應用程式的 Info.plist。

#嵌入應用程式

嵌入 NSH 並以自己的方式辨識語音(自家引擎或服務)的應用程式,可以自行回應 session:在 Rust 中,把 SpeechBackend(nsh_core::speech)交給引擎(EngineConfig::speech),它會接收每個 session 的 SpeechRequest,以及可從任何執行緒回報的 SpeechSink,並回傳核心用來停止和中止該 session 的 SpeechControl。此時不會使用平台的辨識器。

#測試

nsh test 會在 headless 執行中以模擬辨識器運行 session:session 會立即開始,app.hear(text) 把 text 回報為到目前為止的逐字稿,app.endSpeech(error?) 會結束 session,而 app.speechRequests() 會列出應用程式已開始的 session。參閱 測試。

#Storage

Storage API 把應用程式的資料保存在裝置上:以字串鍵儲存字串,下次啟動應用程式時仍然存在。可用於設定、草稿、使用者最後開啟的分頁、伺服器所傳資料的快取,以及任何應在應用程式行程結束後仍須保留的狀態。Rust 程式碼使用 nsh_core::storage::Storage,TSX 從 nsh 匯入 Storage;兩者讀寫相同的項目,因此同時有 Rust 入口和 TSX 入口的應用程式看到的是同一組資料。

use nsh_core::storage::Storage;
const Storage: {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
  keys(): Promise<string[]>;
  clear(): Promise<void>;
};
TS Rust(nsh_core::storage::Storage)
await Storage.getItem(key) Storage::get_item(key) -> Option<String>
await Storage.setItem(key, value) Storage::set_item(key, value) -> Result<(), StorageFull>
await Storage.removeItem(key) Storage::remove_item(key)
await Storage.keys() Storage::get_all_keys() -> Vec<String>
await Storage.clear() Storage::clear()

Rust 函式是同步的,因為各項目都在記憶體中:請在元件運行的地方呼叫(元件主體、handler 和任務)。

每個應用程式都有自己的儲存空間,鍵和值合共最多 5 MB。呼叫會按應用程式發出的順序執行,因此 setItem 之後的 getItem 無須等待寫入就能看到新值。讀取來自記憶體;寫入會在背景分批寫入磁碟。

平台 資料存放位置
iOS 應用程式容器中的 Library/Application Support/nsh/<app id>/storage.json
Android 應用程式私有目錄中的 files/nsh/<app id>/storage.json
macOS ~/Library/Application Support/<app id>/storage.json
Windows %APPDATA%\<app id>\storage.json
Linux $XDG_DATA_HOME/<app id>/storage.json(~/.local/share/…)
Web 瀏覽器的 localStorage,鍵以應用程式 id 命名(nsh:com.example.todo:…)

已損毀的 storage.json 會在其旁邊保留為 storage.json.corrupt,而應用程式會以空的儲存空間啟動;無法讀取的檔案則會讓應用程式的變更只留在記憶體中,因此該檔案永遠不會被取代。

應用程式 id 是 nsh.json 中的 id,並隨應用程式一起傳遞:nsh build 和 nsh package 會把它寫在 bundle 旁邊,開發伺服器會傳送它,已簽署的套件則帶有其發佈金鑰的憑證所保證的 id。因此,無論在何處運行,每個應用程式都無須任何設定就能保存自己的資料;沒有 id 的應用程式會把資料保存在 default 之下。嵌入應用程式可以選擇其他位置(參閱 在 iOS 上嵌入 和 在 Android 上嵌入)。

#Storage.getItem

getItem(key: string): Promise<string | null>;

以 key 的值 resolve,沒有值時則為 null。Rust:Storage::get_item(key),沒有值時為 None。

let theme = Storage::get_item("theme").unwrap_or_else(|| "system".into());

#Storage.setItem

setItem(key: string, value: string): Promise<void>;

把 key 設為 value,取代原有的內容。當儲存空間會超過 5 MB 時,以 Error reject,其 code 為 "ERR_NSH_QUOTA_EXCEEDED";此時該值不會被保存。Rust:Storage::set_item(key, value),此時會回傳 Err(StorageFull)。值是字串:結構化資料請以 JSON 保存(Rust 用 serde_json::to_string,TSX 用 JSON.stringify),再讀回來(serde_json::from_str、JSON.parse)。

let draft = serde_json::json!({ "title": title.get(), "body": body.get() });
if let Err(full) = Storage::set_item("draft", draft.to_string()) {
    log::warn!("{full}");
}

#Storage.removeItem

removeItem(key: string): Promise<void>;

移除 key 及其值。移除沒有值的鍵不會有任何作用。Rust:Storage::remove_item(key)。

#Storage.keys

keys(): Promise<string[]>;

按順序以所有鍵 resolve。Rust:Storage::get_all_keys()。

#Storage.clear

clear(): Promise<void>;

移除應用程式的所有鍵,例如在使用者登出時。Rust:Storage::clear()。

#在裝置上保存 signal

應用程式啟動時讀取已儲存的值,之後每當 signal 改變就寫回去:

use nsh_core::{storage::Storage, prelude::*};

#[component]
pub fn App() -> impl IntoElement {
    let saved = Storage::get_item("count").and_then(|n| n.parse().ok());
    let count = signal(saved.unwrap_or(0_u32));
    effect(move || {
        if let Err(full) = Storage::set_item("count", count.get().to_string()) {
            log::warn!("{full}");
        }
    });
    view()
        .on_press(move || count.update(|n| *n += 1))
        .child(text(move || format!("Pressed {} times", count.get())))
}

#host

host 用於連接嵌入 NSH 的應用程式:即 NshAppView / NshView 周圍的原生應用程式,在網頁上則是運行 player 的頁面。應用程式以名稱和 JSON 參數呼叫 host 的函式,並監聽 host 傳送的事件。

import { host } from "nsh";
const host: {
  readonly params: { readonly [key: string]: unknown };
  post(name: string, data?: unknown): void;
  available(): boolean;
  call(name: string, args?: unknown, options?: { timeout?: number }): Promise<unknown>;
  on(event: string, listener: (payload: unknown) => void): () => void;
};

#host.available

available(): boolean;

表示平台是否有會回應呼叫的 host。在網頁上,當頁面為 player 提供 host 時為 true(網頁上的 host)。

import { Text } from "nsh";
import { host } from "nsh";

<Text>{host.available() ? "Embedded" : "Standalone"}</Text>;

#host.call

call(name: string, args?: unknown, options?: { timeout?: number }): Promise<unknown>;

以 args(任何 JSON 值;沒有時為 null)呼叫 host 的函式 name,並以其回傳值 resolve。host.call 永不拋出例外:所有失敗都會 reject promise,並附上 Error,其 code 說明發生了什麼事(訊息供人閱讀,可能會更改):

code 情況
ERR_NSH_HOST_ERROR host 的函式失敗,或 host 沒有函式 name;訊息由 host 提供
ERR_ACCESS_DENIED name 不在應用程式的 host 權限 之內;permission 為 "host",resource 為該名稱
ERR_FEATURE_UNAVAILABLE_ON_PLATFORM 平台沒有 host:host.available() 為 false
ETIMEDOUT host 未在時限內回應
ERR_NSH_QUOTA_EXCEEDED 已有 1024 個呼叫在等待嵌入應用程式的回應

無效的名稱(由 1 至 128 個 A–Z、a–z、0–9、_、.、:、- 組成,且不含 ..)會以 TypeError reject。

呼叫會等待 host 回應 30 秒,之後以 ETIMEDOUT reject;較遲到達的回應會被丟棄。options.timeout 以毫秒設定等待時間,0 表示不設限;大到時鐘永遠無法達到的逾時值(約 1e22 ms)同樣表示無限等待,而負數、NaN 或無限大的值,則會以 RangeError reject 該呼叫。時間以應用程式的時間為準,與其計時器相同:在使用手動時鐘的 nsh test 中,時間隨時鐘移動。

const user = (await host.call("getUserInfo")) as { name: string };
const quote = await host.call("quote.refresh", { symbol: "AAPL" }, { timeout: 5000 }).catch((error) => {
  if (error.code === "ETIMEDOUT" || error.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM") return null;
  throw error;
});

#host.on

on(event: string, listener: (payload: unknown) => void): () => void;

平台事件會以 host 傳送的每個 event 的 payload 呼叫 listener,直到呼叫 host.on 所回傳的函式為止;在元件渲染期間加入的 listener 會隨該元件一起停止。host 在應用程式啟動前傳送的事件,會在應用程式啟動後送達,因此在應用程式頂層註冊的 listener 都能收到;在 listener 註冊之前傳送的事件,則不會重播給它。應用程式啟動時的狀態請透過 host.call 讀取,其後的變化則以 host.on 跟進。

const stop = host.on("loginChanged", (payload) => console.log(payload));
// later
stop();

#權限

應用程式呼叫的每個函式,都必須列在 nsh.json 的 capabilities.host 中:可以是完整名稱、以 * 結尾的前綴,或以 true 代表全部。每個應用程式都適用此規則,即使沒有其他 capabilities 亦然:host 的函式預設絕不會授予。未獲授權的呼叫會以 ERR_ACCESS_DENIED 拒絕並記錄;對嵌入應用程式以此類名稱發出 host.post 會拋出相同的錯誤;而建置匯入 host(來自 nsh)但沒有 capabilities.host 的應用程式時會發出警告。事件無需授權。參見沙箱與權限。

{ "capabilities": { "host": ["getUserInfo", "bridge.*"] } }

#網頁上的 host

在網頁上,頁面本身就是 host。createPlayer 的 host 選項負責回應呼叫,player.hostEvent(event, payload) 則發送事件:

const player = await createPlayer(container, {
  wasm: "./nsh.wasm",
  host: { call: (name, args) => (name === "getUserInfo" ? { name: "Ada" } : Promise.reject(new Error(name))) },
});
player.hostEvent("loginChanged", { signedIn: true });

已發佈的應用程式在 nsh.json 中指定其 host:web.host 是應用程式目錄內的 JS 模組(相對於 nsh.json),匯出 createHost(player),並回傳帶有 call 的物件。nsh package --target web 會將它隨網站一併發佈,nsh run --target web 會在開發頁面上執行它,nsh test 則會為其測試所開啟的應用程式執行它。web.connect 列出 host 自身請求可連線的 host,頁面的 Content-Security-Policy 會連同應用程式的 capabilities.network 一併允許這些連線。參見 Web。

{ "web": { "host": "./host.js", "connect": ["api.example.com"] } }

#host.params

嵌入應用程式在啟動你的應用程式之前設定的參數,可在程式碼第一行同步讀取;若未設定任何參數則為 {}。這些參數在你的應用程式執行期間保持不變:之後的請求會以事件形式送達。

const conversation = host.params.conversation as string | undefined;
mount(() => (conversation ? <Conversation id={conversation} /> : <Inbox />));

#Native SDK 通道

Native SDK 的 send 會保留事件直至應用程式開始監聽,最多 256 個。若最後一個監聽器在派發期間停止,其餘事件會依次序繼續等待;在關閉期間發送的事件會等待下一個載入的應用程式。host.post(name, data) 將資料傳送給 Native SDK 處理器,處理器的 isPost 用來區分 post 與 call。

#嵌入應用程式一側

每個 view 為其載入的所有應用程式保留一條通道。請在載入前設定參數,可隨時從任何執行緒發送事件,並以名稱註冊處理器。呼叫與 post 需要應用程式的 capabilities.host 授權。處理器只會回應一次,可即時或稍後回應;若回應始終沒有出現,該次呼叫便會失敗。

// Any platform's AppView (GPUI, macOS, Windows, Linux, iOS): its HostChannel.
use serde_json::json;

let host = view.host();
let mut params = serde_json::Map::new();
params.insert("conversation".into(), json!("42"));
host.set_params(params);
// Runs on the engine thread: answer at once, or move the call and answer later from any thread.
host.handle("user.profile", |call| {
    let id = call.data()["id"].clone();
    call.resolve(json!({ "id": id, "name": "Ada" }));
});
host.send("conversation.open", json!({ "id": "43" })).unwrap();

參見 Embedding on iOS、Embedding on Android 及 GPUI。

#keymap

keymap 用來綁定應用程式的鍵盤快速鍵:一個按鍵組合(例如 "mod+k")及其執行的函式。Rust 程式碼從 nsh_core::prelude 呼叫 on_key、on_key_with 及 key_label;TSX 則從 nsh 匯入 keymap。兩者接受相同的按鍵組合,並遵循相同的規則。

use nsh_core::prelude::*; // on_key, on_key_with, KeyOptions, key_label
const keymap: {
  bind(keystroke: string, handler: (e: { keystroke: string }) => void, options?: KeyBindingOptions): () => void;
  bind(bindings: Record<string, (e: { keystroke: string }) => void>, options?: KeyBindingOptions): () => void;
  label(keystroke: string): string;
};

interface KeyBindingOptions {
  when?: "always" | "notEditing";
  repeat?: boolean;
}
TS Rust
keymap.bind(keystroke, handler) on_key(keystroke, handler)
keymap.bind(keystroke, handler, { when, repeat }) on_key_with(keystroke, KeyOptions { when, repeat }, handler);when 為 nsh_core::keymap::When(Auto、Always、NotEditing)
keymap.bind({ … }) 每個按鍵組合一個 on_key
keymap.label(keystroke) key_label(keystroke)

Rust 綁定的存續時間與建立它的元件(擁有者)相同;處理器收到的是按原樣書寫的按鍵組合。

快速鍵可用於 macOS、Windows、Linux 及瀏覽器(瀏覽器自身的按鍵,例如 Ctrl+T 或 Cmd+W,永遠不會傳到頁面),亦可在接上實體鍵盤的 iOS 及 Android 上使用:包括 iPad 的鍵盤、執行 iPad 應用程式的 Mac 的鍵盤、平板電腦或 Chromebook 的鍵盤,以及模擬器的鍵盤。同一份程式碼可在各處執行;沒有鍵盤時,任何按鍵都不會觸發綁定。

#keymap.bind

bind(keystroke: string, handler: (e: { keystroke: string }) => void, options?: KeyBindingOptions): () => void;

當按下 keystroke 時執行 handler,並回傳一個可移除該綁定的函式。處理器會收到 { keystroke }:即你所寫的按鍵組合,大小寫保持原樣。Rust:on_key(keystroke, handler),其處理器收到的是按鍵組合本身。

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let open = signal(false);
    on_key("mod+k", move |_| open.set(true));
    on_key("escape", move |_| open.set(false));
    text(move || if open.get() { "Search…" } else { "Press ⌘K" })
}

傳入物件即可一次綁定多個按鍵組合;回傳的函式會移除全部綁定。在 Rust 中,每個按鍵組合各為一個 on_key:

on_key("mod+s", move |_| save());
on_key("mod+shift+p", move |_| open_palette());
on_key("f5", move |_| refresh());

在元件渲染期間建立的綁定會隨該元件一併移除,因此畫面的快速鍵與畫面同生共滅:

#[component]
fn Editor() -> impl IntoElement {
    on_key("mod+enter", move |_| send());
    input().multiline(true)
}

使用開發伺服器時,編輯模組會先移除其頂層程式碼所建立的綁定,然後才再次執行,因此熱重載不會留下過時的快速鍵。

#keymap.label

label(keystroke: string): string;

回傳 keystroke 在應用程式執行環境中,選單與提示所顯示的樣式:在 macOS 及 iOS(以及 Mac、iPhone 或 iPad 上的瀏覽器)為 "⇧⌘L",在 Windows、Linux 及其他瀏覽器為 "Ctrl+Shift+L",對象為 "mod+shift+l"。Rust:key_label(keystroke)。可用它以實際有效的按鍵顯示應用程式的快速鍵:

text(format!("Search {}", key_label("mod+k")))

對於無效的按鍵組合,它會拋出 TypeError,與 bind 相同;key_label 及 on_key 會以相同的訊息 panic。

#按鍵組合

按鍵組合由修飾鍵和一個按鍵以 + 連接而成:"mod+k"、"ctrl+shift+p"、"alt+enter"、"escape"、"f5"、"cmd+-"(Command 加減號)。

修飾鍵 macOS Windows Linux
mod(亦可寫作 cmdorctrl) Command (⌘) Ctrl Ctrl
cmd(亦可寫作 command、meta、super、win) Command (⌘) Windows 鍵 Super
ctrl(亦可寫作 control) Control (⌃) Ctrl Ctrl
alt(亦可寫作 option、opt) Option (⌥) Alt Alt
shift Shift (⇧) Shift Shift

iOS 沿用 macOS 的規則(mod 即 Command),Android 則沿用 Windows 與 Linux 的規則(mod 即 Ctrl,cmd 為 Meta 鍵)。在瀏覽器中,按鍵依瀏覽器所在的平台而定:mod 在 Mac、iPhone 或 iPad 上是 Command,在其他平台上則是 Ctrl。

應用程式自身的快速鍵請使用 mod:這是各平台應用程式用於此類快速鍵的按鍵(macOS 上為 ⌘K,Windows 與 Linux 上為 Ctrl+K),因此一種寫法即可通用。Windows 鍵與 Super 大多由系統使用,所以在 macOS 以外,cmd 很少是合適的選擇。

按鍵可以是字母、數字或標點符號鍵,以其在美式鍵盤上不按 Shift 時所輸入的字元書寫(k、1、,、/、[;"shift+/" 代表 ?,"shift+k" 代表 Shift+K),或者是一個名稱:plus(= / + 鍵,亦可寫作 =)、minus(亦可寫作 -)、space、enter(return)、escape(esc)、tab、backspace、delete(del)、insert、方向鍵 up、down、left、right(arrowup,…)、home、end、pageup、pagedown、f1 至 f24。大小寫無關:"Mod+Shift+K" 即 "mod+shift+k",大寫字母與小寫字母是同一個鍵,並不代表 Shift("mod+K" 即 "mod+k")。建議使用小寫,本文件各頁亦採用此寫法。"mod-k" 與 "mod++" 屬於錯誤寫法,其錯誤訊息會提示正確的寫法。nsh/desktop 中的選單快速鍵接受相同的按鍵組合。

快速鍵適用於任何鍵盤配置:在西里爾字母或希臘字母配置下,mod+k 是在美式配置下輸入 K 的那個鍵,與選單快速鍵相同。

對於無效的按鍵組合(keymap.bind: invalid keystroke "cmd+kk": unknown key "kk"),bind 會拋出 TypeError 並說明問題所在;同時它會拒絕系統自行保留的按鍵組合,因此應用程式絕不會破壞這些按鍵:

平台 保留按鍵
macOS、iOS Cmd+Q(結束)、Cmd+H(隱藏)、Option+Cmd+H(隱藏其他)、Cmd+M(最小化)、Ctrl+Cmd+F(全螢幕)
Linux、Windows、Android Ctrl+Q(結束)、Alt+F4(關閉)、F11(全螢幕)
Web 無(瀏覽器保留自己的按鍵)

#輸入文字時

取得鍵盤焦點的文字輸入框需要使用它的按鍵:輸入文字、移動插入點、刪除,以及全選、剪下、複製、貼上、復原和重做。因此預設情況下,當文字輸入框(或 web view)取得焦點時,若按鍵組合屬於以下其中一種,綁定便不會觸發:單獨的按鍵、只帶 Shift 或 Option 的按鍵、方向鍵、Home、End、Page Up / Down、Return、Tab、Escape、Backspace、Delete,以及 mod+a、mod+c、mod+v、mod+x、mod+z。其他帶有 Command 或 Control 的按鍵組合及功能鍵,無論焦點在哪裡都會觸發。

when 可改變此行為(Rust:KeyOptions { when: When::Always, .. }):

when 觸發時機
(預設) 如上所述
"always" 文字輸入框中亦會觸發,且早於輸入框接收按鍵
"notEditing" 文字輸入框取得焦點時從不觸發
use nsh_core::keymap::When;

// Sends from the message field too.
on_key_with("mod+enter", KeyOptions { when: When::Always, ..KeyOptions::default() }, move |_| send());
// Only when the user is not typing.
on_key_with("mod+e", KeyOptions { when: When::NotEditing, ..KeyOptions::default() }, move |_| archive());

在 iOS 及 Android 上,輸入法正在組字所用的按鍵(選擇候選字、確認輸入)仍歸輸入法所有。在 GPUI 後端上,取得鍵盤焦點的原生元件會被視為文字輸入框,因為它可能是文字編輯器。它會最先接收每個按鍵:它自行處理的按鍵永遠不會傳到綁定,而在編輯文字時會觸發的綁定,則會收到它放行的按鍵。

#哪個綁定會執行

觸發了綁定的按鍵會被消耗:它不會被輸入,系統不會發出提示音,NSH 自身的按鍵也看不到它(Escape 返回、Tab 移動焦點、方向鍵捲動)。當多個綁定使用相同的按鍵組合時,最新且可觸發的那個會執行,因此對話框可以在畫面的綁定之上綁定 Escape,對話框的綁定移除後,畫面的綁定便會恢復。綁定會在按鍵按下的一刻於原生層檢查;處理器隨後立即在引擎執行緒(Rust)或 JS 執行緒(TSX)上執行。

按住按鍵只會觸發一次綁定,直至該按鍵被放開;使用 repeat: true 時,每次自動重複都會再次觸發(例如逐項走過清單):

on_key_with("alt+down", KeyOptions { repeat: true, ..KeyOptions::default() }, move |_| next_item());

選單快速鍵會讓位給相同按鍵組合的綁定:所有桌面平台上的應用程式選單(nsh/desktop),以及 macOS 上內建選單的快速鍵(Cmd+W、Cmd+C 等)。使用 nsh run 開發時,Cmd+R / Ctrl+R 一律會重新載入應用程式,即使它綁定了 mod+r;已封裝的應用程式則可以綁定它。

#nativeComponent 與 nativeModule

nativeComponent 與 nativeModule 在執行時以名稱將你的應用程式連接到 host 應用程式所提供的內容。嵌入 NSH 的 iOS 或 Android 應用程式可以按名稱註冊自己的 view(地圖、圖表、相機預覽);nativeComponent 為其中一個提供具型別的元件。由於 NSH 以真正的原生 view 渲染,此類元件會與其他 view 一樣位於 view 階層中:由 NSH 排版,隨其父層一同裁切與捲動,並能正確地重疊。從 Rust 嵌入 NSH 的應用程式亦可註冊函式、類別及 Rust 元件;nativeModule 會將它們整合為一個具型別的物件。無論哪種情況,你的應用程式都以標準的 nsh build 建置:host 在執行應用程式時才連接它所提供的內容。Rust 程式碼使用 nsh_core::prelude 的 native(name) 放置已註冊的 view,以 .prop(name, value) 設定其 props(JSON 值,或其 signal 或閉包),或以 .bind_prop(name, &value) 將其中一個綁定到動畫值,以 .on_native_event(…) 接收其事件,並透過 ElementRef 呼叫其指令;TSX 則匯入 nsh。

use nsh_core::prelude::*; // native, NativeEvent, element_ref

關於註冊原生一側,請參見 Native components,以及 Embedding on iOS / Embedding on Android。

#nativeComponent

function nativeComponent<P extends object = {}, E extends object = {}>(
  name: string,
  spec: NativeComponentSpec<P, E>,
): (props: NativeComponentProps<P, E>) => JSX.Element;

interface NativeComponentSpec<P, E> {
  /** Its own props: sent to the component (any JSON value), each when it changes. */
  props: readonly (keyof P & string)[];
  /** The events it emits. */
  events?: readonly (keyof E & string)[];
}

回傳嵌入應用程式以 name 註冊的原生元件所對應的元件(Swift NshAppView.register(component:),Java NshView.registerComponent)。Rust:native(name),這是一個 view,其 props 為 JSON 值(.prop("text", json!(…))),其事件以 NativeEvent { name, data } 送達。

  • P 是其自身 props 的型別。spec.props 中列出的每個 prop 在變更時都會以 JSON 傳送到原生一側;將它設為 undefined 即會移除該 prop。
  • E 將事件名稱對應到其負載。事件 regionChange 會成為處理器 prop onRegionChange。
  • 此元件亦接受除 children 以外的所有 view prop:style、class、排版、無障礙、觸控處理器及 ref。
type NativeComponentProps<P, E> = Omit<ViewProps, "children"> & P & NativeEventHandlers<E>;
type NativeEventHandlers<E> = { [K in keyof E & string as `on${Capitalize<K>}`]?: (data: E[K]) => void };

#大小

此元件與 view 一樣排版,大小由其樣式決定。樣式未指定某個維度時,會採用原生元件回報的大小(如同圖片採用其自然大小)。

#指令

它的 ref 接收一個 NativeComponentRef;ref.command(name, args) 以 args(任何 JSON 值)呼叫原生元件的指令 name。Rust:ElementRef::command(name, args)。

map.command("zoomTo", json!({ "level": 3 }));

#未註冊時

若 name 之下沒有註冊任何內容(例如不同的 host 應用程式、開發 host、瀏覽器),元件會渲染一個空的 view,並只記錄一次警告。請確保應用程式在沒有它時仍可使用,或在使用前先檢查你的 host。

#範例

這裡的徽章是 host 應用程式的原生 view,註冊為 embed.badge。NSH 負責排版、向它發送 text 與 color、接收其 tap 事件,並呼叫其 pulse 指令:

use nsh_core::prelude::*;
use serde_json::json;

#[component]
pub fn App() -> impl IntoElement {
    let taps = signal(0_u64);
    let badge = element_ref();
    view()
        .flex_1()
        .p_5()
        .gap_4()
        .child(
            native("embed.badge")
                .prop("text", json!("Tap the native badge"))
                .prop("color", json!("#8250df"))
                .element_ref(&badge)
                .on_native_event(move |e: &NativeEvent| {
                    if &*e.name == "tap" {
                        taps.set(e.data["count"].as_u64().unwrap_or(0));
                        badge.command("pulse", json!(null));
                    }
                })
                .self_start()
                .rounded(px(8.0))
                .overflow_hidden(),
        )
        .child(text(move || format!("Tapped {}×", taps.get())))
}

#nativeModule

function nativeModule<T extends object>(specifier: string): T;

傳回嵌入式應用程式以 specifier 註冊的集合所匯出的內容:其模組、類別、函式和 Rust 元件,合為一個物件。類型 T 由應用程式自行撰寫,因此建置時無需任何有關宿主的資訊。與宿主 crate 一同建置的 Rust 程式碼會以 Rust 方式呼叫這些函式;nativeModule 則是 TSX 存取它們的途徑。

import { nativeModule, signal } from "nsh";

interface TradingApi {
  quote(symbol: string): number;
  placeOrder(symbol: string, quantity: number, side: "Buy" | "Sell"): Promise<Order>;
  on(event: "change", listener: (desk: Desk) => void): () => void;
}

const { Trading } = nativeModule<{ Trading: TradingApi }>("host/trading");

const price = Trading.quote("AAPL");

const desk = signal<Desk | null>(null);
Trading.on("change", (d) => desk.set(d));
  • 呼叫方式與透過 import 引入集合時相同:一般的 Rust 函式會即時傳回其值,async 函式會傳回 promise,而你傳入的函式則成為 Rust 日後可以呼叫的回呼。Rust 會檢查每個引數,遇到錯誤的引數時會拋出 TypeError。
  • 以相同的 specifier 每次呼叫都會傳回同一個物件。
  • 若 specifier 之下沒有註冊任何內容,nativeModule 會拋出指明該名稱的 ReferenceError。請在應用程式能夠處理該錯誤的位置呼叫它,或只在其所針對的宿主中呼叫。

宿主在啟動其檢視之前,透過 Rust 以平台 crate 的 set_ui_sets(與 NSH 自身的集合並列)註冊集合,或使用 EngineConfig::ui_sets:

use std::{cell::RefCell, rc::Rc};

use nsh_core::prelude::*;

pub struct Trading;

#[module]
impl Trading {
    /// A sample price.
    pub fn quote(symbol: String) -> Result<f64, String> { /* ... */ }

    /// Calls `listener` with the desk now and after every change
    /// (`change`, the one event), until the returned function is called
    /// or what called `on` goes.
    pub fn on(event: String, listener: Callback<Desk>, cx: CallCx<'_>) -> Result<Stop, String> {
        if event != "change" {
            return Err(format!("no Trading event `{event}`"));
        }
        let listener = Rc::new(RefCell::new(Some(listener)));
        let held = listener.clone();
        cx.start(async move {
            let mut desk = DESK.subscribe(); // a tokio watch channel the host keeps
            loop {
                let Some(listener) = held.borrow().clone() else { return };
                listener.post(desk.borrow_and_update().clone());
                if desk.changed().await.is_err() {
                    return;
                }
            }
        });
        Ok(Stop::new(move || drop(listener.take())))
    }
}

let mut trading = Registry::ui_set("host/trading");
trading.module::<Trading>();
nsh_platform_gpui::set_ui_sets(vec![nsh_base::registry(), trading]);

on 是宿主讓所有應用程式與其自身資料保持同步的方式:它啟動的任務會等待宿主的變更,並將每項變更傳送給應用程式,因此由宿主或由任何應用程式透過其他函式所作的變更,都會傳達至每個正在監聽的應用程式。它傳回的 Stop 就是 TSX 中 Trading.on 所傳回的函式:它只會執行一次,在應用程式呼叫它時,或在呼叫 on 的對象結束時(元件,或熱重載下模組的執行)執行,並在此處移除監聽器,任務隨之結束。

#無障礙功能

無障礙功能 API 會讀取用戶的無障礙設定(是否有螢幕閱讀器正在執行、用戶是否要求減少動態效果),並與螢幕閱讀器溝通。Rust 程式碼從 nsh_core::prelude 呼叫 screen_reader()、reduce_motion() 和 announce_for_accessibility(),並以 ElementRef 移動焦點;TSX 則從 nsh 匯入 screenReaderEnabled()、reduceMotion()、announce() 和 setAccessibilityFocus()。這些設定具響應性:在繫結、computed 或 effect 中讀取它們時,當用戶更改設定,讀取它們的內容也會隨之更新。元素的無障礙屬性(accessibilityLabel、role 等)記載於元素;指南請參閱無障礙功能。

use nsh_core::prelude::*; // screen_reader, reduce_motion, announce_for_accessibility, element_ref

#screenReaderEnabled

const screenReaderEnabled: Accessor<boolean>;

是否有螢幕閱讀器(VoiceOver、TalkBack)正在執行。具響應性:當用戶開啟或關閉螢幕閱讀器時,讀取它的內容會隨之更新。Rust:screen_reader() -> bool,同樣具響應性。

when(move || !screen_reader(), || text("Swipe for the next photo"))
    .otherwise(move || text("Next photo").on_press(next))

#reduceMotion

const reduceMotion: Accessor<boolean>;

用戶是否要求減少動態效果(iOS 的「減少動態效果」,Android 的「移除動畫」)。具響應性。Rust:reduce_motion() -> bool。Tailwind 類別也會透過 motion-reduce: 變體跟隨此設定。

let open = move || {
    if reduce_motion() {
        panel.set(0.0);
    } else {
        let _ = panel.spring(0.0, Spring::default());
    }
};

#announce

function announce(message: string): void;

螢幕閱讀器會朗讀 message(VoiceOver 播報、TalkBack)。適用於在焦點沒有移動的情況下出現的結果,例如「已儲存」。Rust:announce_for_accessibility(message)。

#setAccessibilityFocus

function setAccessibilityFocus(node: ViewRef): void;

將螢幕閱讀器的焦點移至 node(元素的 ref)。Rust:ElementRef::set_accessibility_focus()。

use nsh_core::prelude::*;

#[component]
fn Saved() -> impl IntoElement {
    let note = element_ref();
    let save = move || {
        announce_for_accessibility("Saved");
        note.set_accessibility_focus();
    };
    // Runs again when the setting changes, until the component goes.
    effect(|| log::info!("screen reader {}", screen_reader()));
    view()
        .gap_2()
        .child(text("Save").role(AccessibilityRole::Button).on_press(save))
        .child(text("Your changes are kept on this device.").element_ref(&note))
}

#類型

nsh 亦會匯出響應式 API 和宿主元素的類型。最常用的包括:

類型 說明
Accessor<T> 可讀取的值:() => T
Signal<T> 具有 set 和 update 的 Accessor<T>
SignalOptions<T> signal 和 computed 的選項(equals)
Context<T> context 的傳回值
Component<P> (props: P) => JSX.Element
JSX JSX 命名空間(JSX.Element)
ViewRef、InputRef、ScrollRef、ListRef、WebViewRef、VideoRef、NativeComponentRef ref 所接收的內容,即各元素自身的類型(ViewRef 及其上層類型):焦點、捲動及其他命令。ViewRef 同時也是元素本身的類型(JSX.Element)。請參閱 ref 方法
ViewProps、TextProps、ImageProps、ScrollProps、CommonProps、TouchProps 宿主元素的屬性
ViewStyle、TextStyle、InputStyle、ImageStyle、LayoutStyle、StyleProp<T> 樣式物件;請參閱樣式參考
ColorValue、DimensionValue、LengthValue、TransformStep 樣式內的值
GestureResponderEvent、MouseEvent、MouseButton、ContextMenuEvent、PressEvent、ScrollEvent、PanEvent、TouchPoint 事件負載
Point、Size、Bounds、Edges、Modifiers 構成負載和屬性的結構,欄位與 GPUI 一致(nsh_core::geometry)
PixelSize 圖片或影片本身的像素尺寸(onLoad、Image.getSize、剪貼簿和選取器的圖片),而 Size 則以點(points)為單位
ImageAsset 匯入圖片檔案所得的結果({ url, size: Size, scales? };scales 列出其像素密度變體)
OS "ios" | "android" | "macos" | "windows" | "linux" | "web"
AccessibilityRole、AccessibilityState 無障礙屬性
import { type GestureResponderEvent, type InputRef, signal } from "nsh";

let field: InputRef | undefined;
const onPress = (e: GestureResponderEvent) => field?.focus();

StyleProp<T> 接受一個樣式,或由左至右合併的樣式清單,並會略過 falsy 的項目:

<View style={[styles.card, selected() && styles.selected]} />