元素

Sheet

Sheet 是位于屏幕一侧边缘的面板,背后的背景层覆盖其余部分:筛选条件、分享面板、侧边抽屉。按下背景层、Escape、Android 返回键和 VoiceOver 的退出手势会关闭它。它显示在所在窗口中一切内容之上,在每种设备上都一样,自己不绘制任何东西:面板和背景层都属于应用。

el-sheet.tsx
el-sheet.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
import { desktop, Inspector, Log, Panel, size, Stage, ui, wide } from "./palette";

type Side = "bottom" | "top" | "left" | "right";

/** The panel's corners and size across, by the edge it is on. */
function panel(side: Side) {
  const r = 20;
  const across = side === "left" || side === "right" ? { width: wide() ? 320 : 280 } : {};
  const corners = {
    bottom: { borderTopLeftRadius: r, borderTopRightRadius: r, paddingBottom: 32 },
    top: { borderBottomLeftRadius: r, borderBottomRightRadius: r, paddingTop: 32 },
    left: { borderTopRightRadius: r, borderBottomRightRadius: r },
    right: { borderTopLeftRadius: r, borderBottomLeftRadius: r },
  }[side];
  return { ...ui().card, padding: 20, gap: 12, ...across, ...corners };
}

function App() {
  const open = signal(false);
  const side = signal<Side>("bottom");
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 6));
  const show = (s: Side) => {
    side.set(s);
    open.set(true);
    log(`open · side ${s}`);
  };
  /** A button that opens the sheet at side `s`. */
  const opener = (s: Side) => (
    <View
      onPress={() => show(s)}
      activeStyle={{ opacity: 0.7 }}
      style={{
        ...(s === side() ? ui().accentFill : ui().raised),
        height: wide() ? size.control : 42,
        paddingHorizontal: wide() ? 12 : 0,
        borderRadius: wide() ? 8 : 10,
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: s === side() ? ui().onAccent : ui().text }}>side="{s}"</Text>
    </View>
  );
  const changes = () => <Log title="onOpenChange(open, reason) · onCancel" lines={events()} minHeight={130} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>sheet</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Open a side; click the backdrop or press Escape to close it" : "Open a side; tap the backdrop or Android's back to close it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            {/* Each button at the edge its sheet comes from. */}
            <Stage width={360} gap={12}>
              <View style={{ alignItems: "center" }}>{opener("top")}</View>
              <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
                {opener("left")}
                {opener("right")}
              </View>
              <View style={{ alignItems: "center" }}>{opener("bottom")}</View>
            </Stage>
            <Inspector>
              <Panel title="open · side">
                <Text style={{ fontSize: 13, color: ui().text }}>
                  {open() ? "open" : "closed"} · {side()}
                </Text>
              </Panel>
              {changes()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>
              <View style={{ width: 240, gap: 8 }}>
                <Each of={["top", "left", "right", "bottom"] as Side[]}>{(s) => opener(s())}</Each>
              </View>
            </View>
            {changes()}
          </>
        )
      }

      <Sheet
        open={open()}
        closeOnSwipe
        side={side()}
        onOpenChange={(next, reason) => {
          open.set(next);
          log(`onOpenChange · ${next ? "opened" : "closed"} · ${reason}`);
        }}
        onCancel={() => log("onCancel")}
        popupStyle={panel(side())}
        backdropStyle={{ backgroundColor: "rgba(0,0,0,0.45)" }}
      >
        <Text style={{ fontSize: 17, fontWeight: "700", color: ui().text }}>Filters</Text>
        <Text style={{ fontSize: 14, color: ui().muted }}>
          A panel at the {side()} edge. {desktop ? "The backdrop and Escape close it." : "The backdrop and Android's back close it."}
        </Text>
        <View
          onPress={() => {
            open.set(false);
            log("Done pressed");
          }}
          activeStyle={{ opacity: 0.7 }}
          style={
            wide()
              ? { ...ui().primary, alignSelf: "flex-end", height: size.control, paddingHorizontal: 14, borderRadius: 8, justifyContent: "center" }
              : { ...ui().primary, height: 42, borderRadius: 10, alignItems: "center", justifyContent: "center" }
          }
        >
          <Text style={{ fontSize: wide() ? 13 : 14, fontWeight: "600", color: ui().onPrimary }}>Done</Text>
        </View>
      </Sheet>
    </View>
  );
}

mount(App);
use nsh_base::{SheetEvent, sheet};
use nsh_core::ui::prelude::*;

fn filters(open: Signal<bool>) -> impl IntoElement {
    sheet()
        .open(move || Some(open.get()))
        .on_open_change(move |e: &SheetEvent| {
            if let SheetEvent::OpenChange(next, _) = e {
                open.set(*next);
            }
        })
        .child(
            view()
                .gap_3()
                .p_6()
                .rounded_t(px(16.0))
                .bg(white())
                .child(text("Filters").font_weight(FontWeight::SEMIBOLD))
                .child(view().on_press(move || open.set(false)).child(text("Done"))),
        )
}

Sheet 来自 nsh/base。它的内容在每次打开时创建。side 把它放在屏幕的 "bottom"(默认)或 "top",宽度与屏幕相同;或放在 "left" 或 "right",高度与屏幕相同;它另一个方向的尺寸由面板决定,来自其内容或 popupClass。

入场滑动距离会跟随面板的实际测量尺寸。内容在入场期间延迟创建或异步增高时,面板沿用当前动画进度,并按更新后的尺寸滑入到位。

#打开状态

没有 open 时,sheet 自己保存状态,初始值为 defaultOpen。有 open 时,状态由应用持有:在 onOpenChange(open, reason) 中修改它,每次关闭的请求都会带着原因("backdropPress"、"cancel"、"swipe")报告给它。无论是谁打开或关闭了它,onOpen 和 onClose 都会在它进入和离开后触发。

closeOnEscape={false} 让它在 Escape 和返回键时保持打开,closeOnBackdropPress={false} 让它在按下背景层时保持打开;onCancel 仍会收到这些操作,可以先询问用户。

#拖动底部面板

设置 closeOnSwipe 后,底部面板会跟随下拉,遮罩随距离变淡。松手时,下拉距离达到面板高度的 30%, 或向下速度达到每秒 800 点,会以 "swipe" 原因请求关闭;较短的拖动会平滑回弹。 受控模式下,业务保持 open 为 true 即可让面板回弹。减少动态效果开启时,松手和程序开关会立即到位, 直接拖动仍然跟手。

面板按内容高度布局,适合矮菜单;业务通过 popupStyle 或子元素高度约束较长内容,并自行绘制拖动条。 iOS 中,内部竖向 Scroll 或 List 先消费滚动,到顶部后把同一次手势剩余的下拉位移交给面板。 Android、桌面和 Web 使用已有的原生 pan 支持拖动条及滚动区域外的内容拖动,内部滚动沿用各平台的手势仲裁。 将 closeOnSwipe 改为 false 后面板保持原位,内容和滚动位置保留。其他 side 保持现有行为。

#原生 sheet

Sheet 由 NSH 在应用的窗口中绘制,样式由应用决定。要使用平台自己呈现的 sheet(iOS 上的 page sheet),请使用 presentation="pageSheet" 的 Dialog。

#属性

属性Rust类型默认值说明
open.open(..)boolean无是否打开,由应用控制。
defaultOpen.default_open(..)booleanfalse没有 open 时,初始是否打开。
side.side(..)"top" | "bottom" | "left" | "right""bottom"它所在的边缘。
closeOnEscape.close_on_escape(..)booleantrueEscape 和 Android 返回键会关闭它。
closeOnBackdropPress.close_on_backdrop_press(..)booleantrue按下背景层会关闭它。
closeOnSwipe.close_on_swipe(..)booleanfalse底部面板跟随下拉,并在松手达到阈值时请求关闭。
popupClass、popupStylepopup_class、popup_stylestring、StyleProp<ViewStyle>无面板的外观。
backdropClass、backdropStylebackdrop_class、backdrop_stylestring、StyleProp<ViewStyle>无背景层的外观,比如半透明的颜色。
children.child(..)JSX.Element无内容。

#事件

事件Rust说明
onOpenChange.on_open_change(|e| ..)它关闭了,附带原因:"backdropPress"、"cancel" 或 "swipe"。
onOpen.on_open(|| ..)它已进入:完全显示出来。
onClose.on_close(|| ..)它已离开:完全消失。应用关闭它时也会触发。
onCancel.on_cancel(|| ..)用户尝试关闭它(Escape 或返回键、按下背景层、已启用的下拉关闭),无论它是否关闭。