元素
View
View 是每個畫面的基本構件:一個原生容器(在 iOS 和 Mac Catalyst 上是 UIView,在 Android 上是 ViewGroup),以 flexbox 或 grid 排列子元件,繪製背景、邊框、陰影和漸變,並處理按壓。按鈕、卡片、列和畫面全部都是 view。Rust 以 view() 建立,TSX 則從 nsh 引入 <View> 來建立。
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, desktop, focusRing, Inspector, Panel, Stage, ui, wide } from "./palette";
function App() {
const presses = signal(0);
const size = signal("");
const views = () => (
<>
<View
onPress={() => presses.update((n) => n + 1)}
{...button("accent", { align: "center" })}
>
<Text style={buttonText("accent")}>activeStyle opacity · pressed {presses()}×</Text>
</View>
<View
onPress={() => presses.update((n) => n + 1)}
activeStyle={{ backgroundColor: ui().border }}
style={{ ...ui().card, backgroundImage: "none", borderRadius: 16, padding: 16 }}
hoverStyle={{ opacity: 0.88 }}
focusStyle={focusRing()}
>
<Text style={{ color: ui().text, fontSize: wide() ? 14 : 16 }}>activeStyle background ({desktop ? "click" : "tap"} and hold)</Text>
</View>
<View class="flex-row gap-3">
<View class="flex-1 h-20 rounded-xl bg-[#ff6170] hover:bg-[#ff7d89] active:bg-[#d94d5b] items-center justify-center" focusStyle={focusRing()} onPress={() => {}}>
<Text class="text-[#13160c] font-semibold">active:</Text>
</View>
<View class="flex-1 h-20 rounded-xl bg-[#45d49a] items-center justify-center shadow-lg">
<Text class="text-[#0c0d0f] font-semibold">shadow-lg</Text>
</View>
<View
class="flex-1 h-20 rounded-xl items-center justify-center"
style={{ backgroundImage: `linear-gradient(135deg, ${ui().violet}, ${ui().info})` }}
>
<Text class="font-semibold" style={{ color: ui().page }}>gradient</Text>
</View>
</View>
</>
);
const measured = (height?: number) => (
<View
onLayout={({ bounds }) => size.set(`${Math.round(bounds.size.width)} × ${Math.round(bounds.size.height)} pt`)}
style={{
flex: height === undefined ? 1 : undefined,
height,
borderRadius: 12,
borderWidth: 2,
borderStyle: "dashed",
borderColor: ui().faint,
alignItems: "center",
justifyContent: "center",
}}
>
<Text style={{ color: ui().muted }}>onLayout: {size()}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page }}>
{() =>
wide() ? (
<View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
<Text style={{ color: ui().muted }}>Click and hold the views; resize the window to see onLayout</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={480} gap={16}>
{views()}
{measured(120)}
</Stage>
<Inspector>
<Panel title="onPress">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>pressed {presses()}×</Text>
</Panel>
<Panel title="onLayout" grow>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{size()}</Text>
</Panel>
</Inspector>
</View>
</View>
) : (
<View style={{ flex: 1, width: "100%", maxWidth: 640, alignSelf: "center", padding: 20, paddingTop: 24, gap: 16 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>view</Text>
{views()}
{measured()}
</View>
)
}
</View>
);
}
mount(App);use nsh_core::prelude::*;
fn app() -> impl IntoElement {
let liked = signal(false);
view()
.flex_1()
.p_6()
.gap_4()
.bg(rgb(0xf6f8fa))
.child(
view()
.flex_row()
.items_center()
.gap_3()
.p_4()
.rounded(px(16.0))
.bg(white())
.shadow_md()
.child(view().size(px(40.0)).rounded_full().bg(rgb(0x8250df)))
.child(text("A card").flex_1().text_base())
.child(
view()
.hit_slop(EdgeInsets::all(px(8.0)))
.active(|s| s.opacity(0.5))
.on_press(move || liked.update(|l| *l = !*l))
.child(text(move || if liked.get() { "♥" } else { "♡" }).text_size(px(22.0))),
),
)
}import { mount, signal, Text, View } from "nsh";
function App() {
const liked = signal(false);
return (
<View style={{ flex: 1, padding: 24, gap: 16, backgroundColor: "#f6f8fa" }}>
<View
style={{
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 16,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
flexDirection: "row",
alignItems: "center",
gap: 12,
}}
>
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: "#8250df" }} />
<Text style={{ flex: 1, fontSize: 16 }}>A card</Text>
<View onPress={() => liked.set(!liked())} activeStyle={{ opacity: 0.5 }} hitSlop={8}>
<Text style={{ fontSize: 22 }}>{liked() ? "♥" : "♡"}</Text>
</View>
</View>
</View>
);
}
mount(App);#版面配置
View 以 flexbox 排列子元件,預設值為:flexDirection: "column"、flexShrink: 0、alignContent: "flex-start"、position: "relative"。display: "grid"(Rust 中為 .grid())會切換為 CSS grid,display: "block" 則切換為區塊版面。尺寸單位為 point。請參閱版面配置及樣式參考。
display: "flex" 遵循 CSS:在樣式中、作為 flex class 以及 Rust 的 .flex() 時,除非設定了方向(flexDirection: "column"、flex-col、.flex_col()),否則會以橫向排列。沒有設定 display 的 view 會將子元件以直向排列。
#Props
View 本身只有一個 prop:selectable(Rust 中為 .selectable(true)):view 內的文字會作為同一個選取範圍一起被選取(文字選取)。其餘的都是通用 props:樣式(style、class、classList;Rust 中為 Styled 方法)、disabled、按壓 props(onPress、onLongPress、onPressDown、onPressUp、delayLongPress、hitSlop)、狀態樣式(activeStyle、hoverStyle、focusStyle、disabledStyle、checkedStyle)、onMouseEnter / onMouseMove / onMouseLeave、onContextMenu、onFileDragEnter / onFileDragLeave / onFileDrop、focusable、onLayout、ref、nativeID、testID、手勢 props(pan、pinch、rotation 及其事件)、觸控和 responder props、無障礙 props,以及 ios / android / desktop。
#可選取的 view
設定 selectable 後,選取範圍會按閱讀順序跨越 view 內的 Text 元素和 TextView;設有 selectable={false} 的文字不會被選取,而位於另一個可選取 view 內的可選取 view,則屬於外層 view 的一部分。各平台的手勢請參閱文字選取。
<View selectable class="gap-3 p-4">
<Text>One selection runs from here…</Text>
<Text>…to here.</Text>
</View>
#按壓回饋
可按壓的 view 是指任何設有按壓處理器的 view。其回饋由應用程式決定:按下時套用的樣式,會在手指按下期間以原生方式套用,無需往返應用程式的程式碼。hoverStyle、focusStyle 和 disabledStyle 以相同方式處理其他狀態:
// Fade the whole view.
view().on_press(open).active(|s| s.opacity(0.6))
// Show a background; group_active styles the children.
view()
.group()
.on_press(open)
.active(|s| s.bg(rgb(0xe5e7eb)))
.child(text("Open").group_active(|s| s.opacity(0.7)))
// Change any style while pressed.
view().on_press(open).bg(rgb(0x2563eb)).active(|s| s.bg(rgb(0x1e40af)).scale(0.95))// Fade the whole view.
<View onPress={open} activeStyle={{ opacity: 0.6 }}>…</View>
// Show a background; group-active: styles the children.
<View onPress={open} class="group" activeStyle={{ backgroundColor: "#e5e7eb" }}>
<Text class="group-active:opacity-70">Open</Text>
</View>
// Change any style while pressed with active: classes.
<View onPress={open} class="bg-blue-600 active:bg-blue-800 active:scale-95">…</View>在 Android 上,android={{ ripple: { color: "#0000001f" } }}(Rust 中為 .ripple(Ripple { .. }))會加入 Material 波紋效果(可用 borderless、radius 和 foreground 調整)。
disabled 會從原生 view 移除按壓監聽器(不會有按壓,也沒有回饋),並套用停用樣式:
view()
.on_press(submit)
.disabled(move || !valid.get())
.bg(rgb(0x16a34a))
.disabled_style(|s| s.opacity(0.4))<View onPress={submit} disabled={!valid()} class="bg-green-600 disabled:opacity-40">…</View>#事件
| 事件 | Rust | 資料內容 | 觸發時機 |
|---|---|---|---|
onPress |
.on_press |
PressEvent(Rust 無) |
輕點在 view 內結束。 |
onLongPress |
.on_long_press |
PressEvent(Rust 無) |
手指持續按住 delayLongPress(500 毫秒)。 |
onPressDown / onPressUp |
.on_press_down / .on_press_up |
PressEvent(Rust 無) |
手指按下 / 離開或移出。 |
onMouseEnter / onMouseLeave |
.on_mouse_enter / .on_mouse_leave |
MouseEvent |
滑鼠移到 view(或其內的 view)上 / 離開 view 或視窗(懸停);onMouseLeave 的值為滑鼠最後所在的位置。 |
onMouseMove |
.on_mouse_move |
MouseEvent |
滑鼠在 view(或其內的 view)上移動(不論有否按下按鈕),或在滑鼠停留其上且沒有按下按鈕時按下或放開修飾鍵:position、pagePosition(Point)、modifiers 和 pressedButton("left"、"right"、"middle"、"back"、"forward" 或 null);每個影格最多觸發一次。 |
onContextMenu |
.on_context_menu |
ContextMenuEvent |
在 view 上按下次要按鈕(右鍵選單)。 |
onFileDragEnter / onFileDragLeave |
.on_file_drag_enter / .on_file_drag_leave |
無 | 本機檔案拖曳進入 / 離開目前作用中的桌面目標。 |
onFileDrop |
.on_file_drop |
{ uris: string[] }(FileDropEvent) |
本機檔案或目錄被放到桌面目標上。 |
onLayout |
.on_layout |
{ bounds }(Bounds<Points>) |
相對於父元件的 frame 已改變。 |
onPanStart / onPanEnd |
.on_pan_start / .on_pan_end |
PanEvent |
以 pan 綁定的拖曳開始 / 結束;系統取消拖曳時,cancelled 為 true。 |
onPinchStart / onPinchEnd |
.on_pinch_start / .on_pinch_end |
{ scale, velocity } |
以 pinch 綁定的捏合開始 / 結束。 |
onRotationStart / onRotationEnd |
.on_rotation_start / .on_rotation_end |
{ rotation, velocity } |
以 rotation 綁定的旋轉開始 / 結束。 |
onAccessibilityAction |
.on_accessibility_action |
{ actionName } |
自訂無障礙動作已執行。 |
觸控和 responder 事件:請參閱通用 props。
#桌面檔案拖放
在 AppKit、GTK、Win32 和 GPUI 上,設有 onFileDrop 的 view 可接收從其他應用程式拖曳過來的本機檔案和目錄。onFileDragEnter 和 onFileDragLeave 負責追蹤目前作用中的目標;onFileDrop 會按原生檔案清單的順序接收 { uris: string[] }。每個項目都是絕對 file: URL,其中的空格和非 ASCII 字元已經過跳脫處理。請解析該 URL 以取得檔案系統路徑,而不要手動移除前綴。
let hovering = signal(false);
let files = signal(Vec::<String>::new());
view()
.min_h(px(160.0))
.p_6()
.on_file_drag_enter(move || hovering.set(true))
.on_file_drag_leave(move || hovering.set(false))
.on_file_drop(move |event: &FileDropEvent| {
files.set(event.uris.clone());
hovering.set(false);
})
.child(text(move || if hovering.get() { "Release files here" } else { "Drop files here" }))
.child(text(move || files.get().join("\n")))const hovering = signal(false);
const files = signal<string[]>([]);
<View
onFileDragEnter={() => hovering.set(true)}
onFileDragLeave={() => hovering.set(false)}
onFileDrop={(event) => {
files.set(event.uris);
hovering.set(false);
}}
style={{ minHeight: 160, padding: 24 }}
>
<Text>{hovering() ? "Release files here" : "Drop files here"}</Text>
<Text>{files().join("\n")}</Text>
</View>最接近且已啟用、並監聽 onFileDrop 的上層元件會接收放置的檔案;modal root 會限定此搜尋範圍。disabled 會移除檔案拖放監聽器;離開、取消、完成和目標移除都會清除作用中狀態。接受放置時使用 Copy 操作及已掛載的原生狀態。應用程式自行決定是否讀取、開啟或傳輸所接收的檔案。
#Ref 方法
一般 view 的 ref 是 ViewRef:focus()、blur() 和 measure()。ref(Rust 中為 .element_ref(&handle))可取得其節點,例如傳給 setAccessibilityFocus(nsh 的無障礙 API),或用來量度該節點(Rust 中為 handle.measure())。
#文字內的 view
放在文字內的 view 屬於行內附件:它位於基線上,並隨文字一起換行,且可獨立接收按壓。請參閱文字。
#平台備註
boxShadow在所有平台上都會繪製;android={{ elevation }}則改為使用 Android 的 Material 陰影。在 Android 上,陰影需要背景顏色(背景透明的 view 不會投射陰影)。- View 上的
filter:iOS 支援opacity()、最大為 1 的brightness()和drop-shadow()(圖片支援所有顏色濾鏡和blur());Android 自 API 31 起全部支援,更低版本則只支援顏色濾鏡。 backdropFilter: "blur(…)"在 iOS 上是系統材質(近似效果),在 Android 上需要 API 31。- Android 繪製
translate、scale和rotate變形;傾斜(skew()、skewX()、skewY())則套用於其他平台。 borderCurve: "continuous"(.border_curve(BorderCurve::Continuous))會繪製與邊緣平滑過渡的圓角(如同 iOS 的應用程式圖示),兩個平台皆適用。cursor樣式會套用於有指標的環境(Mac Catalyst、配有指標的 iPad、桌面平台)。