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_layoutimport { Animated, createAnimatedValue, createMotion, motions, Easing, LayoutAnimation, AnimatedNode, AnimatedValue } from "nsh";指南請參閱動畫。
#動畫值可以驅動什麼
| 位置 | 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))
}import { mount, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";
function App() {
const x = createAnimatedValue(0);
let right = false;
const toggle = () => {
right = !right;
x.spring(right ? 200 : 0);
};
return (
<View style={{ flex: 1, padding: 24, paddingTop: 48, gap: 16 }}>
<View style={{ width: 60, height: 60, borderRadius: 12, backgroundColor: "#6366f1", transform: [{ translateX: x }] }} />
<Text onPress={toggle}>Move</Text>
</View>
);
}
mount(App);#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);
}
});const opacity = createAnimatedValue(1);
const { finished } = await opacity.timing(0, { duration: 200 });
if (finished) 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)))const scroll = createAnimatedValue(0);
const header = scroll.diffClamp(0, 44);
<Scroll scrollValue={scroll} style={{ flex: 1 }}>{/* rows */}</Scroll>
<View style={{ height: 44, transform: [{ translateY: header.mul(-1) }] }} />let progress = animated_value(0.0);
let rotate = progress.interpolate(&[0.0, 1.0], &[0.0, 360.0]); // degrees, for bind_rotate
let color = progress.interpolate_colors(&[0.0, 0.5, 1.0], &[rgb(0x22c55e), rgb(0xeab308), rgb(0xef4444)]);const progress = createAnimatedValue(0);
const rotate = progress.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
const color = progress.interpolate({ inputRange: [0, 0.5, 1], outputRange: ["#22c55e", "#eab308", "#ef4444"] });#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)),
);const y = createAnimatedValue(0);
y.keyframes([0, { offset: 0.3, value: -20, easing: "easeIn" }, 0], { duration: 600, iterations: 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);
})const card = createMotion({ enter: "fadeInUp", delay: 80 });
<View onPress={() => card.play("pulse")} style={{ width: 120, height: 120, borderRadius: 16, backgroundColor: "#0969da", ...card.style }} />#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);card.play({ duration: 400, keyframes: [{ opacity: 0, translateY: 24, easing: "easeOut" }, { opacity: 1, translateY: 0 }] });#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" }) });const ys = [0, 1, 2, 3].map(() => createAnimatedValue(0));
const hop = Animated.stagger(
80,
ys.map((y) =>
Animated.sequence([
Animated.timing(y, { toValue: -36, duration: 220, easing: Easing.out(Easing.quad) }),
Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
]),
),
);
hop.start(({ finished }) => console.log(finished ? "done" : "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() });value.timing(1, { duration: 600, easing: Easing.out(Easing.back()) });
value.timing(1, { easing: Easing.bezier(0.2, 0, 0, 1) });
value.timing(1, { easing: "easeOut" });#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.")))const open = signal(false);
const toggle = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
open.update((o) => !o);
};
<View>
<Text onPress={toggle}>Details</Text>
<If when={open()}>
<Text>Shown with a spring.</Text>
</If>
</View>#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, ...
};import { colorScheme, fontScale, lifecycle, locale, openUrl, pixelRatio, platform, setColorScheme, windowSize } from "nsh";指南請參閱裝置與環境。
#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())const wide = () => windowSize().width >= 600;
<View style={{ flexDirection: wide() ? "row" : "column" }}>{/* ... */}</View>#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() })const dark = () => colorScheme() === "dark";
<View style={{ flex: 1, backgroundColor: dark() ? "#000" : "#fff" }} />#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();
}
});effect(() => {
if (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 };const columns = platform.desktop ? 3 : 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,
};const shadow = platform.select({
ios: { boxShadow: "0px 2px 8px rgb(0 0 0 / 0.15)" },
android: { elevation: 4 },
});#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());const hairline = () => 1 / pixelRatio();#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");
}
});
})<Button
onPress={async () => {
const { opened } = await openUrl("mailto:help@example.com");
if (!opened) {
console.warn("no mail app");
}
}}
>
<Text>Contact us</Text>
</Button>- URL 必須是絕對 URL(帶有 scheme)。會執行程式碼或存取應用程式自身資料的 URL,即
javascript:、data:、blob:、file:、about:和asset:,以及相對位址,都會令 promise 以TypeErrorreject。openUrl從不丟出例外:所有失敗都會 reject。 - 聲明了 capabilities 的應用程式需要
openUrl:"openUrl": true表示所有 scheme,"openUrl": { "schemes": ["https", "mailto"] }表示所列出的 scheme;其他 URL 會以Errorreject,其code為"ERR_ACCESS_DENIED",permission為"openUrl",resource為該 URL(見沙盒與 capabilities)。在 Rust 中,open_url會對這些 URL 回應false並記錄原因。 - 嵌入式應用程式會先看到該 URL,並可改為透過自己的 router 處理(Swift
NshAppView.onOpenURL、JavaNshView.setOpenUrlHandler、RustAppView::host().handle_open_url;見 iOS 和 Android)。此時opened為true。 - 各平台使用自己的開啟方式:iOS 使用
UIApplication.open(嵌入時使用宿主應用程式的 application),Android 使用ACTION_VIEWintent,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"))<View style={{ direction: "rtl", flexDirection: "row", gap: 8 }}>
<Text>First</Text>
<Text>Second</Text>
</View>#相關內容
- 安全區域 insets(Rust
safe_area();TSXsafeArea()、safeAreaInsets())和軟鍵盤(Rustkeyboard_height();TSXkeyboardFrame())請見安全區域與鍵盤。 - 螢幕閱讀器和減少動態效果的設定請見無障礙。
#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, PanGestureimport { PanResponder, type PanResponderGestureState } from "nsh";#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);
}),
)
}import { mount, signal, View } from "nsh";
import { PanResponder } from "nsh";
function App() {
const pos = signal({ x: 0, y: 0 });
let start = { x: 0, y: 0 };
const pan = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => (start = pos()),
onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
onPanResponderRelease: (_, g) => console.log(`released at ${g.vx.toFixed(0)} pt/s`),
});
return (
<View style={{ flex: 1 }}>
<View
{...pan.panHandlers}
style={{
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "#6366f1",
transform: [{ translateX: pos().x }, { translateY: pos().y }],
}}
/>
</View>
);
}
mount(App);#回呼
每個回呼都會接收觸控事件(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))const swipe = PanResponder.create({
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy),
onPanResponderMove: (_, g) => offset.set(g.dx),
onPanResponderRelease: (_, g) => (Math.abs(g.dx) > 120 ? remove() : offset.set(0)),
onPanResponderTerminate: () => offset.set(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;import { Clipboard } from "nsh";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" }))
}import { signal, Text, View } from "nsh";
import { Clipboard } from "nsh";
const copied = signal(false);
<View
onPress={async () => {
await Clipboard.writeText(code());
copied.set(true);
}}
>
<Text>{copied() ? "Copied" : "Copy"}</Text>
</View>;#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());
}
});const pasted = await Clipboard.readText();
if (pasted) code.set(pasted.trim());#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) });const canPaste = signal(false);
Clipboard.hasText().then((has) => canPaste.set(has));#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
}import { Image, signal, View } from "nsh";
import { Clipboard, type ClipboardImage } from "nsh";
const attachments = signal<ClipboardImage[]>([]);
async function paste() {
const image = await Clipboard.readImage();
if (image) attachments.set([...attachments(), image]);
}
async function upload(image: ClipboardImage) {
const body = new FormData();
body.append("file", await (await fetch(image.url)).blob(), "screenshot.png");
await fetch("https://example.com/upload", { method: "POST", body });
}
<View class="flex-row gap-2">
<Each of={attachments()}>
{(image) => <Image src={image().url} style={{ width: 64, height: 64, borderRadius: 8 }} />}
</Each>
</View>;在聊天類應用程式中,使用者會把螢幕截圖貼到輸入框,這類應用程式會在輸入框取得焦點時綁定貼上快速鍵,並在有圖片時取用:
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);
}
});
});import { keymap } from "nsh";
keymap.bind("mod+v", async () => {
if (await Clipboard.hasImage()) return paste();
insertText(await Clipboard.readText());
}, { when: "always" });#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());await Clipboard.writeImage(image.url);#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();const files = await Clipboard.readFiles();
const names = files.map((url) => decodeURIComponent(new URL(url).pathname.split("/").pop()!));#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_imagesimport { Picker, type PickedImage } from "nsh";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
}import { Image, signal, Text, View } from "nsh";
import { Picker } from "nsh";
const attached = signal<{ file: File; src: string } | null>(null);
async function choosePhoto() {
const [picked] = await Picker.pickImages({ maxDimension: 2048, quality: 0.85 });
if (picked) attached.set({ file: picked.file, src: URL.createObjectURL(picked.file) });
}
async function send(text: string) {
const body = new FormData();
body.append("text", text);
if (attached()) body.append("photo", attached()!.file);
await fetch("https://example.com/messages", { method: "POST", body });
if (attached()) URL.revokeObjectURL(attached()!.src);
attached.set(null);
}
<View class="flex-row items-center gap-2">
<View role="button" accessibilityLabel="Choose a photo" onPress={choosePhoto}>
<Text>Photo</Text>
</View>
<If when={attached()}>
<Image src={attached()!.src} style={{ width: 48, height: 48, borderRadius: 8 }} />
</If>
</View>;一次選取多張相片:
let picked = pick_images(PickRequest::new(true, 9));
spawn(async move {
let files = picked.await;
photos.update(|list| list.extend(files));
});const images = await Picker.pickImages({ multiple: true, limit: 9, maxDimension: 2048 });
photos.set([...photos(), ...images.map((image) => URL.createObjectURL(image.file))]);#嵌入應用程式
嵌入 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};import { Speech, type SpeechSession } from "nsh";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;import { Storage } from "nsh";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());const theme = (await Storage.getItem("theme")) ?? "system";#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.setItem("draft", JSON.stringify({ title: title(), body: body() }));#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())))
}import { effect, mount, signal, Text, View } from "nsh";
import { Storage } from "nsh";
const count = signal(0);
const loaded = signal(false);
Storage.getItem("count").then((saved) => {
count.set(Number(saved ?? 0));
loaded.set(true);
});
effect(() => {
if (loaded()) Storage.setItem("count", String(count()));
});
mount(() => (
<View onPress={() => count.update((n) => n + 1)}>
<Text>Pressed {count()} times</Text>
</View>
));#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();appView.params = ["conversation": "42"]
try appView.loadBundle(at: bundleURL)
// On the main thread; return the answer, or throw to reject the call.
appView.handle("user.profile") { data in
let id = (data as? [String: Any])?["id"]
return ["id": id ?? NSNull(), "name": try await profiles.name(of: id)]
}
appView.handle("analytics") { data, _ in analytics.log(data) }
appView.send("conversation.open", ["id": "43"])nsh.setParams(Map.of("conversation", "42"));
nsh.loadAssetBundle("nsh");
// On the UI thread; answer now or later, from any thread.
nsh.handle("user.profile", (data, reply) -> {
Object id = ((Map<?, ?>) data).get("id");
profiles.load(id, profile -> reply.resolve(Map.of("id", id, "name", profile.name)),
error -> reply.reject(error.getMessage()));
});
nsh.send("conversation.open", Map.of("id", "43"));參見 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_labelimport { keymap } from "nsh";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" })
}import { mount, signal, Text } from "nsh";
import { keymap } from "nsh";
const open = signal(false);
keymap.bind("mod+k", () => open.set(true));
keymap.bind("escape", () => open.set(false));
mount(() => <Text>{open() ? "Search…" : "Press ⌘K"}</Text>);傳入物件即可一次綁定多個按鍵組合;回傳的函式會移除全部綁定。在 Rust 中,每個按鍵組合各為一個 on_key:
on_key("mod+s", move |_| save());
on_key("mod+shift+p", move |_| open_palette());
on_key("f5", move |_| refresh());const unbind = keymap.bind({
"mod+s": save,
"mod+shift+p": openPalette,
f5: refresh,
});在元件渲染期間建立的綁定會隨該元件一併移除,因此畫面的快速鍵與畫面同生共滅:
#[component]
fn Editor() -> impl IntoElement {
on_key("mod+enter", move |_| send());
input().multiline(true)
}function Editor() {
keymap.bind("mod+enter", send);
return <Input multiline />;
}使用開發伺服器時,編輯模組會先移除其頂層程式碼所建立的綁定,然後才再次執行,因此熱重載不會留下過時的快速鍵。
#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")))<Text>Search {keymap.label("mod+k")}</Text>對於無效的按鍵組合,它會拋出 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());// Sends from the message field too.
keymap.bind("mod+enter", send, { when: "always" });
// Only when the user is not typing.
keymap.bind("mod+e", archive, { when: "notEditing" });在 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());keymap.bind("alt+down", nextItem, { repeat: true });選單快速鍵會讓位給相同按鍵組合的綁定:所有桌面平台上的應用程式選單(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_refimport { nativeComponent, nativeModule } from "nsh";關於註冊原生一側,請參見 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會成為處理器 proponRegionChange。- 此元件亦接受除
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 }));map.command("zoomTo", { 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())))
}import { mount, type NativeComponentRef, signal, View } from "nsh";
import { nativeComponent } from "nsh";
const Badge = nativeComponent<{ text: string; color?: string }, { tap: { count: number } }>("embed.badge", {
props: ["text", "color"],
events: ["tap"],
});
function App() {
const taps = signal(0);
let badge: NativeComponentRef | undefined;
return (
<View style={{ flex: 1, padding: 20, paddingTop: "calc(env(safe-area-inset-top) + 20px)", gap: 16 }}>
<Badge
ref={(node) => (badge = node)}
text={taps() === 0 ? "Tap the native badge" : `Tapped ${taps()}×`}
color="#8250df"
onTap={(e) => {
taps.set(e.count);
badge?.command("pulse");
}}
style={{ alignSelf: "flex-start", borderRadius: 8, overflow: "hidden" }}
/>
</View>
);
}
mount(App);#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_refimport { announce, reduceMotion, screenReaderEnabled, setAccessibilityFocus } from "nsh";#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))<If when={!screenReaderEnabled()} else={<Text onPress={next}>Next photo</Text>}>
<Text>Swipe for the next photo</Text>
</If>#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());
}
};const open = () => {
if (reduceMotion()) panel.set(0);
else panel.spring(0);
};#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(¬e))
}import { effect, Text, View, type ViewRef } from "nsh";
import { announce, screenReaderEnabled, setAccessibilityFocus } from "nsh";
function Saved() {
let note!: ViewRef;
const save = () => {
announce("Saved");
setAccessibilityFocus(note);
};
// Runs again when the setting changes, until the component goes.
effect(() => console.log("screen reader", screenReaderEnabled()));
return (
<View style={{ gap: 8 }}>
<Text onPress={save} role="button">Save</Text>
<Text ref={note}>Your changes are kept on this device.</Text>
</View>
);
}#類型
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]} />