要素
If / Each
If と Each は、どの要素を存在させるかを決めます。条件付きコンテンツと繰り返しコンテンツを扱い、それ自体はネイティブビューを作りません。
ネイティブレンダラーは変更された部分だけを更新します。If は条件が切り替わると分岐を差し替え、Each はリストを再構築せず、キーに基づいて行の追加、移動、削除を行います。Rust では
nsh_core::prelude に when(..)、each(..)、dynamic(..) があり、TSX ではインポート不要のグローバルである <If> と <Each> を使います。
import { mount, signal, Text, View } from "nsh";
import { button, buttonText, focusRing, hoverFill, Inspector, Panel, size, Stage, ui, wide } from "./palette";
interface Todo {
id: number;
title: string;
done: boolean;
}
let nextId = 4;
function App() {
const todos = signal<Todo[]>([
{ id: 1, title: "Read the elements page", done: true },
{ id: 2, title: "Try <If> and <Each>", done: false },
{ id: 3, title: "Ship the app", done: false },
]);
const showDone = signal(true);
const visible = () => (showDone() ? todos() : todos().filter((t) => !t.done));
const toggle = (id: number) => todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const add = () => todos.update((list) => [...list, { id: nextId++, title: `Task ${nextId - 1}`, done: false }]);
const remove = (id: number) => todos.update((list) => list.filter((t) => t.id !== id));
const actions = () => (
<View style={{ flexDirection: "row", gap: 8 }}>
<View onPress={add} {...button("accent")}>
<Text style={buttonText("accent")}>Add</Text>
</View>
<View onPress={() => showDone.set(!showDone())} {...button()}>
<Text style={buttonText()}>{showDone() ? "Hide done" : "Show done"}</Text>
</View>
</View>
);
const list = () => (
<If
when={visible().length > 0}
else={<Text style={{ color: ui().muted, textAlign: "center", marginTop: 40 }}>Nothing to show.</Text>}
>
<View style={{ gap: wide() ? 6 : 8 }}>
<Each of={visible()} key={(t: Todo) => t.id}>
{(todo, index) => (
<View
style={{
...ui().row,
flexDirection: "row",
alignItems: "center",
gap: 12,
...(wide() ? { borderRadius: 8, height: size.row, paddingHorizontal: 12 } : { borderRadius: 12, padding: 12 }),
}}
>
<View
onPress={() => toggle(todo().id)}
hitSlop={8}
style={{
width: wide() ? 18 : 22,
height: wide() ? 18 : 22,
borderRadius: wide() ? 9 : 11,
borderWidth: 2,
borderColor: ui().accent,
backgroundColor: todo().done ? ui().accent : "transparent",
}}
hoverStyle={{ opacity: 0.88 }}
focusStyle={focusRing()}
activeStyle={{ opacity: 0.7 }}
/>
<Text
style={{
flex: 1,
fontSize: wide() ? size.label : undefined,
color: todo().done ? ui().faint : ui().text,
textDecorationLine: todo().done ? "line-through" : "none",
}}
>
{index() + 1}. {todo().title}
</Text>
<If when={todo().done}>
<View onPress={() => remove(todo().id)} hitSlop={8} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
<Text style={{ color: ui().down, fontSize: wide() ? 13 : undefined }}>Remove</Text>
</View>
</If>
</View>
)}
</Each>
</View>
</If>
);
const counts = () => `${todos().filter((t) => t.done).length} of ${todos().length} done`;
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 }}>if / each</Text>
<Text style={{ color: ui().muted }}>Click a circle to finish a task; Remove shows on finished ones</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={420}>
{/* A fixed height, so the list grows downwards instead of moving. */}
<View style={{ height: 360 }}>{list()}</View>
</Stage>
<Inspector>
{actions()}
<Panel title="todos · showDone">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{counts()}</Text>
<Text style={{ fontSize: 13, color: ui().text }}>
{showDone() ? "every task shown" : `${visible().length} not done shown`}
</Text>
</Panel>
</Inspector>
</View>
</View>
) : (
<View style={{ flex: 1, width: "100%", maxWidth: 560, alignSelf: "center", padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>if / each</Text>
{actions()}
{list()}
<Text style={{ fontSize: 12, color: ui().muted, fontVariant: ["tabular-nums"] }}>{counts()}</Text>
</View>
)
}
</View>
);
}
mount(App);use nsh_core::prelude::*;
view()
.child(
when(move || user.with(|u| u.is_some()), move || {
text(move || format!("Hello, {}", user.with(|u| u.as_ref().map(|u| u.name.clone()).unwrap_or_default())))
})
.otherwise(|| text("Signed out")),
)
.child(each(todos, |t: &Todo| t.id, |todo, _index| text(move || todo.get().title)))<View>
<If when={user()} else={<Text>Signed out</Text>}>
{(u) => <Text>Hello, {u().name}</Text>}
</If>
<Each of={todos()}>{(todo) => <Text>{todo().title}</Text>}</Each>
</View>TSX ではこれらはグローバルです(スコープ内に同名のバインディングがあれば、そちらが優先されます)。JSX 式の内部では通常の JavaScript(cond ? a : b、items.map(…))も使え、Rust では dynamic(move || ..) も使えます。ただしこれらは、読み取る値が変わるたびに要素を作り直します。一方、If と Each(Rust では when と each)は、残る要素を保持します。
#If
条件が成立している間は一方の分岐をレンダリングし、成立しない間はもう一方の分岐(または何も表示しない状態)にします。
| Prop | Rust | Type | 説明 |
|---|---|---|---|
when | when(cond, ..) | T | 条件。Rust では値、シグナル、またはクロージャ。TSX ではその真偽値として評価されます。 |
else | .otherwise(|| ..) | JSX.Element | when が成立しない間レンダリングされます。 |
children | when(.., || ..) | JSX.Element | (value: () => NonNullable<T>) => JSX.Element | when が成立している間レンダリングされます。TSX では、関数を渡すと when の値のアクセサーが渡されます。 |
分岐は条件がその分岐に切り替わったときに作られ、切り替わって外れると、作られたものは破棄されます。TSX の when が真の値から別の真の値へ変わる場合(user() があるオブジェクトから別のオブジェクトに変わるなど)は、分岐は保持され、その内部の式が自動的に更新されます。
let loading = signal(true);
when(move || !loading.get(), || profile())
.otherwise(|| text("Loading…").text_color(rgb(0x6e7781)))const loading = signal(true);
<If when={!loading()} else={<Text style={{ color: "#6e7781" }}>Loading…</Text>}>
<Profile />
</If>;TSX では、関数の子は when が渡した値を読み取ります。その型に null や undefined は含まれないため、分岐に ! は不要です。Rust では、分岐がシグナルを直接読み取るか、dynamic が値を要素にマッピングします。
let user = signal(None::<User>);
dynamic(move || user.get().map(|u| text(u.name)))const user = signal<User | null>(null);
<If when={user()}>{(u) => <Text>{u().name}</Text>}</If>;#Each
リストの各アイテムに対して行をレンダリングします。
| Prop | Rust | Type | 説明 |
|---|---|---|---|
of | each(items, ..) | readonly any[] | null | undefined | アイテム。Rust では Vec、そのシグナル、またはそれを返すクロージャ。null と undefined は何もレンダリングしません。 |
key | each(.., |item| key, ..) | (item: any) => unknown | アイテムの識別子。TSX のデフォルトはアイテム自身です(オブジェクトは参照、プリミティブは値で識別)。 |
children | each(.., .., |item, index| row) | (item: () => any, index: () => number) => JSX.Element | 1 行をレンダリングします。 |
行関数が受け取るのは値ではなくアクセサーです。TSX では item() と index() を、Rust では item.get() と index.get() を呼び出してください(どちらも ReadSignal です)。アイテムが変更されても、キーが残っている行は保持され(位置が変わった場合は移動され)、そのアイテムとインデックスが更新されて、それらを読み取る式だけが再実行されます。新しいキーには新しい行が作られ、キーがなくなった行は削除されます。
#[derive(Clone, PartialEq)]
struct Todo {
id: u32,
title: String,
done: bool,
}
let todos = signal(Vec::<Todo>::new());
each(todos, |t: &Todo| t.id, |todo, index| {
view()
.flex_row()
.gap_2()
.child(text(move || format!("{}.", index.get() + 1)))
.child(
text(move || todo.get().title)
.style_when(move || todo.get().done, |s| s.line_through()),
)
})interface Todo {
id: number;
title: string;
done: boolean;
}
const todos = signal<Todo[]>([]);
<Each of={todos()} key={(t: Todo) => t.id}>
{(todo, index) => (
<View style={{ flexDirection: "row", gap: 8 }}>
<Text>{index() + 1}.</Text>
<Text style={{ textDecorationLine: todo().done ? "line-through" : "none" }}>{todo().title}</Text>
</View>
)}
</Each>;#キーの選び方
- 更新のたびに新しいオブジェクトに置き換えられるアイテム(イミュータブルな更新、
{ ...t, done: true })には、通常は id などのkeyが必要です。 指定しないと、新しいオブジェクトごとに新しい行となり、その要素が作り直されます。Rust では常にキーを指定します。 - TSX では、文字列や数値の配列はキーなしで動作します。値で比較されるためです。
- 各行は独自のオーナーを持ちます。行の中で作成されたエフェクトとクリーンアップは、その行が削除されると終了します。
#Each かリストか
Each はすべての行を作成します。数十から数百程度のアイテムであれば、ビューやスクロールビューの中で使っても問題ありません。
長いデータや上限のないデータには、リストを使ってください。リストはビューポート付近の行だけを作成し、再利用します。
式がどのように更新されるかについては、JSX とコンポーネントとリアクティビティを参照してください。