元素

Dialog

Dialog 在应用其余内容之上、在它自己的原生窗口层中呈现内容:iOS 和 Mac Catalyst 上是一个被 present 的 view controller,Android 上是一个 Dialog,桌面平台和 Web 上是覆盖整个窗口的一层。它用于全屏流程、sheet 和对话框。系统自带的手势(下滑关闭 sheet、Android 返回键、Escape)都能正常工作,并在原生侧决定。Rust 用 nsh_base::dialog() 创建,TSX 用从 nsh/base 导入的 <Dialog>。

el-dialog.tsx
el-dialog.tsx运行中
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))),
                )
        }))
}

在 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, ..))booleantrue为 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(..)booleantrueEscape 和 Android 返回键在原生侧关闭它。
closeOnBackdropPress.close_on_backdrop_press(..)booleantrue按下背景层时关闭它。
closeOnSwipe.close_on_swipe(..)booleantrue下滑 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、styleStyled 方法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(..)booleanfalse内容延伸到状态栏下方;否则从状态栏下方开始。

#打开与关闭

对话框的状态属于应用。用户关闭 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())

每次尝试都会调用 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())),
                ),
        )
})

如果需要系统提示框或操作表,请使用 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 的内容从状态栏下方开始。