元素
Sheet
Sheet 是位于屏幕一侧边缘的面板,背后的背景层覆盖其余部分:筛选条件、分享面板、侧边抽屉。按下背景层、Escape、Android 返回键和 VoiceOver 的退出手势会关闭它。它显示在所在窗口中一切内容之上,在每种设备上都一样,自己不绘制任何东西:面板和背景层都属于应用。
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"))),
)
}import { signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
function Filters() {
const open = signal(false);
return (
<>
<View onPress={() => open.set(true)}><Text>Filters</Text></View>
<Sheet
open={open()}
onOpenChange={(next) => open.set(next)}
popupClass="gap-3 rounded-t-2xl bg-white p-6"
backdropClass="bg-black/40"
>
<Text class="font-semibold">Filters</Text>
<View onPress={() => open.set(false)}><Text>Done</Text></View>
</Sheet>
</>
);
}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(..) | boolean | false | 没有 open 时,初始是否打开。 |
side | .side(..) | "top" | "bottom" | "left" | "right" | "bottom" | 它所在的边缘。 |
closeOnEscape | .close_on_escape(..) | boolean | true | Escape 和 Android 返回键会关闭它。 |
closeOnBackdropPress | .close_on_backdrop_press(..) | boolean | true | 按下背景层会关闭它。 |
closeOnSwipe | .close_on_swipe(..) | boolean | false | 底部面板跟随下拉,并在松手达到阈值时请求关闭。 |
popupClass、popupStyle | popup_class、popup_style | string、StyleProp<ViewStyle> | 无 | 面板的外观。 |
backdropClass、backdropStyle | backdrop_class、backdrop_style | string、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 或返回键、按下背景层、已启用的下拉关闭),无论它是否关闭。 |