元素
Dialog
Dialog 在应用其余内容之上、在它自己的原生窗口层中呈现内容:iOS 和 Mac Catalyst 上是一个被 present 的 view controller,Android 上是一个 Dialog,桌面平台和 Web 上是覆盖整个窗口的一层。它用于全屏流程、sheet 和对话框。系统自带的手势(下滑关闭 sheet、Android 返回键、Escape)都能正常工作,并在原生侧决定。Rust 用 nsh_base::dialog() 创建,TSX 用从 nsh/base 导入的 <Dialog>。
import { mount, signal, Text, View } from "nsh";
import { Button, Dialog, SafeAreaView, Switch } from "nsh/base";
import { desktop, Inspector, Panel, size, Stage, tint, ui, wide } from "./palette";
const track = `${desktop ? "w-9" : "w-12"} rounded-full p-0.5 transition-colors`;
const thumb = desktop ? "size-4 rounded-full transition-transform checked:translate-x-4" : "size-5.5 rounded-full transition-transform checked:translate-x-5";
function App() {
const card = signal(false);
const sheet = signal(false);
const backdropCloses = signal(true);
const cancels = signal(0);
const log = signal("–");
// A pointer gets compact buttons as wide as their labels; a phone, 44-point ones.
const compact = { borderRadius: 8, height: size.control, paddingHorizontal: 14, alignItems: "center" as const, justifyContent: "center" as const };
const primary = () => (desktop ? { ...ui().primary, ...compact } : { ...ui().primary, borderRadius: 10, padding: 12, alignItems: "center" as const });
const secondary = () => (desktop ? { ...ui().raised, ...compact } : { ...ui().raised, borderRadius: 10, padding: 12, alignItems: "center" as const });
const buttonText = desktop ? 14 : 16;
const openCard = () => (
<Button onPress={() => card.set(true)} style={primary()} activeStyle={{ opacity: 0.7 }}>
<Text style={{ color: ui().onPrimary, fontSize: buttonText, fontWeight: "600" }}>Open a card</Text>
</Button>
);
const openSheet = () => (
<Button onPress={() => sheet.set(true)} style={secondary()} activeStyle={{ opacity: 0.7 }}>
<Text style={{ color: ui().text, fontSize: buttonText, fontWeight: "600" }}>Open a sheet</Text>
</Button>
);
const backdrop = () => (
<Switch
checked={backdropCloses()}
onChange={(next) => backdropCloses.set(next)}
class={wide() ? "flex-row items-center gap-2 self-center active:opacity-70" : "flex-row items-center gap-3 rounded-xl p-3 active:opacity-70"}
style={wide() ? undefined : ui().row}
trackClass={track}
trackStyle={backdropCloses() ? ui().accentFill : ui().inset}
thumbClass={thumb}
thumbStyle={backdropCloses() ? { backgroundColor: ui().onAccent } : ui().raised}
>
<Text style={{ color: ui().text, flex: wide() ? undefined : 1, fontSize: desktop ? 13 : undefined }}>closeOnBackdropPress {String(backdropCloses())}</Text>
</Switch>
);
const hint = desktop
? "Close with Escape or a click on the backdrop."
: "Close with a tap on the backdrop, a swipe down or the back button. On iOS the sheet is a system pageSheet.";
const state = () => `open: card ${String(card())} · sheet ${String(sheet())} · onCancel ${cancels()}×`;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 12 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>dialog</Text>
{() =>
wide() ? (
<>
<Text style={{ color: ui().muted }}>{hint}</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage gap={16}>
<View style={{ flexDirection: "row", justifyContent: "center", gap: 8 }}>
{openCard()}
{openSheet()}
</View>
{backdrop()}
</Stage>
<Inspector>
<Panel title="open · onCancel">
<Text style={{ fontSize: 13, color: ui().text }}>{state()}</Text>
</Panel>
<Panel title="onOpenChange(open, reason) · onOpen" grow>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().accent }}>{log()}</Text>
</Panel>
</Inspector>
</View>
</>
) : (
<View style={{ gap: 10 }}>
{openCard()}
{openSheet()}
{backdrop()}
<View style={{ ...ui().inset, borderRadius: 12, padding: 14, gap: 4 }}>
<Text style={{ color: ui().muted, fontSize: 13 }}>{state()}</Text>
<Text style={{ color: ui().accent, fontSize: 15, fontWeight: "600" }}>{log()}</Text>
</View>
<Text style={{ color: ui().faint, fontSize: 13 }}>{hint}</Text>
</View>
)
}
<Dialog
open={card()}
animation="fade"
closeOnBackdropPress={backdropCloses()}
onOpenChange={(open, reason) => {
card.set(open);
log.set(`onOpenChange(${open}, "${reason}")`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: card")}
backdropTestID="card-backdrop"
backdropStyle={{ backgroundColor: tint("#000000", 0.45) }}
style={{ ...ui().card, margin: "auto", width: 320, maxWidth: "90%", borderRadius: 18, padding: 20, gap: 12 }}
>
<Text style={{ color: ui().text, fontSize: 20, fontWeight: "700" }}>Delete this item?</Text>
<Text style={{ color: ui().muted }}>
{backdropCloses()
? "A press on the dimmed backdrop closes it."
: "The backdrop only calls onCancel: close with a button."}
</Text>
<View style={{ flexDirection: "row", gap: 8, justifyContent: desktop ? "flex-end" : undefined }}>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Keep");
}}
style={desktop ? secondary() : { ...secondary(), flex: 1 }}
activeStyle={{ opacity: 0.7 }}
>
<Text style={{ color: ui().text, fontWeight: "600" }}>Keep</Text>
</Button>
<Button
onPress={() => {
card.set(false);
log.set("closed by the app: Delete");
}}
style={desktop ? { ...compact, backgroundColor: ui().down } : { flex: 1, borderRadius: 10, padding: 12, alignItems: "center", backgroundColor: ui().down }}
activeStyle={{ opacity: 0.7 }}
>
<Text style={{ color: "#ffffff", fontWeight: "600" }}>Delete</Text>
</Button>
</View>
</Dialog>
<Dialog
open={sheet()}
presentation="pageSheet"
onOpenChange={(open, reason) => {
sheet.set(open);
log.set(`onOpenChange(${open}, "${reason}")`);
}}
onCancel={() => cancels.update((n) => n + 1)}
onOpen={() => log.set("onOpen: sheet")}
backdropStyle={{ backgroundColor: tint("#000000", 0.3) }}
style={{
backgroundColor: ui().page,
flex: 1,
marginTop: 40,
width: "100%",
maxWidth: 560,
alignSelf: "center",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
>
<SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
<Text style={{ color: ui().text, fontSize: 22, fontWeight: "700" }}>A sheet</Text>
<Text style={{ color: ui().muted }}>
presentation="pageSheet": a system sheet on iOS that follows the finger when swiped down. Elsewhere the
dialog is a layer over the window, and this panel is the app's own style.
</Text>
<Button onPress={() => sheet.set(false)} style={desktop ? { ...primary(), alignSelf: "flex-end" } : primary()} activeStyle={{ opacity: 0.7 }}>
<Text style={{ color: ui().onPrimary, fontSize: buttonText, fontWeight: "600" }}>Close</Text>
</Button>
</SafeAreaView>
</Dialog>
</View>
);
}
mount(App);Dialog 负责行为,外观交给应用:它的样式设定弹出层(包含子元素的视图)的样式,在 TSX 中 backdropClass / backdropStyle 在它后面加一层背景。
use nsh_base::{button, dialog, safe_area_view};
use nsh_core::ui::prelude::*;
/// 白色文字的蓝色按钮。
fn primary(label: &'static str, on_press: impl Fn() + 'static) -> impl IntoElement {
button()
.on_press(on_press)
.items_center()
.rounded_lg()
.bg(rgb(0x2563eb))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text(label).font_weight(FontWeight::SEMIBOLD).text_color(white()))
}
fn example() -> impl IntoElement {
let open = signal(false);
view()
.flex_1()
.justify_center()
.p_6()
.child(primary("打开", move || open.set(true)))
.child(when(open, move || {
dialog()
.presentation(ModalPresentation::PageSheet)
.on_cancel(move || open.set(false))
.flex_1()
.bg(white())
.child(
safe_area_view()
.flex_1()
.p_5()
.gap_3()
.child(text("一个 sheet").text_2xl().font_weight(FontWeight::BOLD))
.child(primary("关闭", move || open.set(false))),
)
}))
}import { signal, Text, View } from "nsh";
import { Button, Dialog, SafeAreaView } from "nsh/base";
function Example() {
const open = signal(false);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24 }}>
<Button onPress={() => open.set(true)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">打开</Text>
</Button>
<Dialog open={open()} presentation="pageSheet" onOpenChange={(next) => open.set(next)} class="flex-1 bg-white">
<SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
<Text style={{ fontSize: 24, fontWeight: "700" }}>一个 sheet</Text>
<Button onPress={() => open.set(false)} class="items-center rounded-lg bg-blue-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">关闭</Text>
</Button>
</SafeAreaView>
</Dialog>
</View>
);
}在 TSX 中,open 不为 false 时显示,变为 false 时关闭。在 Rust 中,它在视图中时呈现,离开视图时消失,所以由 when 持有它。无论哪种方式,它都会给内容提供 dialog 的安全区域 inset,供其内部的 SafeAreaView 和 safeAreaInsets()(Rust 组件中为 cx.insets())读取。Button 同样来自 nsh/base,是由应用设定样式的按压目标。
nsh_core::ui::prelude 中也有一个较旧的、已弃用的 dialog 函数:像上面那样按名称导入 nsh_base::dialog,显式导入会取代它。如果需要系统提示框或操作表,请使用 AlertDialog。
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
open | 打开期间位于视图中(when(open, ..)) | boolean | true | 为 true 时显示。 |
animation | .animation(ModalAnimation::Fade) | "slide" | "fade" | "none" | "slide" | 出现和离开的方式。 |
presentation | .presentation(ModalPresentation::PageSheet) | "fullScreen" | "pageSheet" | "formSheet" | "fullScreen" | 在 iOS 上的形式:fullScreen 以透明背景覆盖整个屏幕;pageSheet 和 formSheet 是系统 sheet,下面的内容保持可见(在大屏上 formSheet 是一个更小的居中 sheet)。 |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape 和 Android 返回键在原生侧关闭它。 |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | 按下背景层时关闭它。 |
closeOnSwipe | .close_on_swipe(..) | boolean | true | 下滑 sheet(iOS、手机浏览器)时在原生侧关闭它。 |
statusBar | .status_bar(..) | { foreground?, hidden?, animated? } | 无 | iOS 和 Android:对话框打开期间的状态栏,位于页面的设置之上(状态栏)。 |
onOpen | .on_open(..) | () => void | 已完全出现。 | |
onClose | .on_close(..) | () => void | 已离开:用户关闭了它,或应用移除了它。 | |
onCancel | .on_cancel(..) | () => void | 用户尝试关闭它(Escape、返回、按下背景层、下滑),无论 closeOn* 是否允许关闭。 | |
onOpenChange | .on_cancel(..) | (open: boolean, reason: "cancel" | "backdropPress" | "swipe") => void | 用户关闭了它:在这里同步 open。在 Rust 中,由 on_cancel 把对话框移出视图。 | |
class、style | Styled 方法 | string、StyleProp<ViewStyle> | 弹出层:包含子元素的视图。 | |
backdropClass、backdropStyle | 铺满对话框的子视图 | string、StyleProp<ViewStyle> | 铺满 dialog、位于弹出层后面的背景层,设置其中之一时出现。 | |
children | .child(..) | JSX.Element | 内容。 |
ios 键 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
supportedOrientations | .supported_orientations(..) | ("portrait" | "portrait-upside-down" | "landscape" | "landscape-left" | "landscape-right")[] | 与呈现者相同 | dialog 允许的方向。 |
android 键 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
statusBarTranslucent | .status_bar_translucent(..) | boolean | false | 内容延伸到状态栏下方;否则从状态栏下方开始。 |
#打开与关闭
对话框的状态属于应用。用户关闭 dialog(Escape、Android 返回键、按下背景层、下滑 sheet)时,它在原生侧关闭并通知应用,由应用把状态改成一致:TSX 中是 onOpenChange(false, reason),Rust 中是 on_cancel。在 TSX 中,open 一直为 true 时 dialog 保持关闭,open 从 false 变为 true 时再次打开。
由于主线程从不等待应用代码,每个 closeOn* 标志都在用户操作时由原生侧读取。要在关闭前先确认,把这些标志设为 false:此时用户的尝试会调用 onCancel,由应用自己关闭对话框(TSX 中把 open 设为 false,Rust 中把它移出视图):
dialog()
.presentation(ModalPresentation::PageSheet)
.close_on_escape(false)
.close_on_backdrop_press(false)
.close_on_swipe(false)
.on_cancel(move || ask_to_discard.set(true))
.flex_1()
.bg(white())
.child(form())<Dialog
open={open()}
presentation="pageSheet"
closeOnEscape={false}
closeOnBackdropPress={false}
closeOnSwipe={false}
onCancel={() => askToDiscard.set(true)}
class="flex-1 bg-white"
>
…
</Dialog>每次尝试都会调用 onCancel,无论这些标志是否允许关闭。
#带背景层的对话框
默认 fullScreen 形式的 dialog 显示应用绘制的内容:这里是一层变暗的背景和居中的卡片。按下背景层即关闭:
when(confirm, move || {
dialog()
.animation(ModalAnimation::Fade)
.on_cancel(move || confirm.set(false))
.flex_1()
.child(
// 背景层:按下即关闭对话框。
view()
.absolute()
.inset_0()
.bg(rgba(0x00000066))
.on_press(move || confirm.set(false)),
)
.child(
view()
.m(auto())
.w_80()
.gap_3()
.rounded_2xl()
.bg(white())
.p_5()
.child(text("删除这一项?").text_xl().font_weight(FontWeight::BOLD))
.child(
button()
.on_press(remove)
.items_center()
.rounded_lg()
.bg(rgb(0xdc2626))
.px_4()
.py_3()
.active(|s| s.opacity(0.7))
.child(text("删除").font_weight(FontWeight::SEMIBOLD).text_color(white())),
),
)
})<Dialog
open={confirm()}
animation="fade"
onOpenChange={(next) => confirm.set(next)}
backdropClass="bg-black/40"
class="m-auto w-80 gap-3 rounded-2xl bg-white p-5"
>
<Text style={{ fontSize: 20, fontWeight: "700" }}>删除这一项?</Text>
<Button onPress={remove} class="items-center rounded-lg bg-red-600 px-4 py-3 active:opacity-70">
<Text class="font-semibold text-white">删除</Text>
</Button>
</Dialog>如果需要系统提示框或操作表,请使用 nsh/base 的 AlertDialog。
#ref 方法
无。
#平台说明
- iOS 和 Mac Catalyst:
presentation决定形式。fullScreen以透明背景覆盖整个屏幕;pageSheet和formSheet是带系统背景的系统 sheet,下滑时跟随手指(closeOnSwipe)。 - Android:dialog 铺满窗口,显示应用的弹出层和背景层。返回键首先交给最上层的 dialog,然后才是导航栈和
BackHandler监听器。 - 桌面平台(macOS、Windows、Linux、GPUI)和 Web:dialog 是覆盖整个窗口的透明层,显示应用的弹出层和背景层。Escape 是一次关闭尝试。
supportedOrientations属于 iOS,statusBarTranslucent属于 Android。设置statusBarTranslucent之前,Android dialog 的内容从状态栏下方开始。