元素
Overlay
浮层把它的子元素显示在所在根视图中一切内容之上:窗口,或包住它的对话框。父元素的尺寸、overflow: hidden 和滚动视图都不会裁剪它。除此之外,它就待在你写它的地方:它读取周围的 context,它的处理函数属于它所在的组件,并随父元素一起移除。弹出框、菜单、工具提示和 sheet 都建立在它之上;nsh/base 已经提供了它们(Popover、Menu、Tooltip、Sheet),要构建你自己的弹出层,就用浮层。
import { mount, Overlay, signal, Text, View, type YNode } from "nsh";
import { desktop, Inspector, Panel, Segmented, size, Stage, ui, wide } from "./palette";
type Placement = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
function App() {
const open = signal(false);
const placement = signal<Placement>("bottom");
const align = signal<Align>("center");
const position = signal("—");
const closed = signal("—");
let anchor: YNode | undefined;
const placements = () => (
<Segmented
label="placement"
options={["top", "bottom", "left", "right"] as Placement[]}
value={placement()}
onChange={(v) => (placement.set(v), open.set(true))}
/>
);
const aligns = () => (
<Segmented label="align" options={["start", "center", "end"] as Align[]} value={align()} onChange={(v) => (align.set(v), open.set(true))} />
);
const subject = () => (
<>
<View
style={{
width: 220,
height: 72,
overflow: "hidden",
borderRadius: 14,
borderWidth: 2,
borderStyle: "dashed",
borderColor: ui().faint,
alignItems: "center",
justifyContent: "center",
}}
>
<View
ref={anchor}
onPress={() => open.set(!open())}
activeStyle={{ opacity: 0.7 }}
style={{
...ui().accentFill,
borderRadius: desktop ? 8 : 10,
paddingHorizontal: desktop ? 14 : 18,
height: desktop ? size.control : 40,
justifyContent: "center",
}}
>
<Text style={{ fontSize: desktop ? 14 : 15, fontWeight: "600", color: ui().onAccent }}>{open() ? "Close" : "Open"}</Text>
</View>
<If when={open()}>
<Overlay
anchor={anchor}
placement={placement()}
align={align()}
offset={8}
margin={12}
onPosition={(e) =>
position.set(
`${e.placement} at ${Math.round(e.bounds.origin.x)}, ${Math.round(e.bounds.origin.y)} · ${Math.round(e.bounds.size.width)}×${Math.round(e.bounds.size.height)} · room ${Math.round(e.available.width)}×${Math.round(e.available.height)}`,
)
}
onPressOutside={(e) => {
closed.set(`onPressOutside at ${Math.round(e.pagePosition.x)}, ${Math.round(e.pagePosition.y)}`);
open.set(false);
}}
onCancel={() => {
closed.set("onCancel");
open.set(false);
}}
style={{
...ui().card,
borderRadius: 12,
padding: 14,
gap: 4,
// Narrow beside the button, so it fits on a phone's side.
maxWidth: !wide() && (placement() === "left" || placement() === "right") ? 130 : 220,
}}
>
<Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>On top of everything</Text>
<Text style={{ fontSize: 13, color: ui().muted }}>
Its box clips the button, not this overlay. Pick a side {wide() ? "on the right" : "above"}.
</Text>
</Overlay>
</If>
</View>
<Text style={{ marginTop: 6, fontSize: 12, color: ui().faint }}>The dashed box has overflow: hidden</Text>
</>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>overlay</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click the button, then click outside or press Escape" : "Tap the button, then tap outside"}
</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage>
<View style={{ alignItems: "center" }}>{subject()}</View>
</Stage>
<Inspector>
{placements()}
{aligns()}
<Panel title="onPosition">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{position()}</Text>
</Panel>
<Panel title="closed by" grow>
<Text style={{ fontSize: 13, color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
</Panel>
</Inspector>
</View>
) : (
<>
<View style={{ flexDirection: "row", gap: 12 }}>
<View style={{ flex: 3 }}>{placements()}</View>
<View style={{ flex: 2 }}>{aligns()}</View>
</View>
<View style={{ flex: 1, minHeight: 160, alignItems: "center", justifyContent: "center" }}>{subject()}</View>
<View style={{ ...ui().inset, borderRadius: 12, padding: 12, gap: 6 }}>
<Text style={{ fontSize: 12, color: ui().muted }}>
onPosition <Text style={{ color: ui().text }}>{position()}</Text>
</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>
closed by <Text style={{ color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
</Text>
</View>
</>
)
}
</View>
);
}
mount(App);use nsh_core::ui::prelude::*;
fn more_button() -> impl IntoElement {
let open = signal(false);
let anchor = element_ref();
view()
.child(
view()
.element_ref(&anchor)
.on_press(move || open.update(|o| *o = !*o))
.px_3()
.py_2()
.child(text("More")),
)
.child(when(move || open.get(), move || {
overlay()
.anchor(&anchor)
.placement(Placement::Bottom)
.align(Align::Start)
.offset(px(6.0))
.margin(px(8.0))
.on_press_outside(move |_: &Point<Points>| open.set(false))
.on_cancel(move || open.set(false))
.rounded_lg()
.bg(white())
.p_3()
.shadow_lg()
.child(text("Shown above everything"))
}))
}import { Overlay, signal, Text, View, type YNode } from "nsh";
function MoreButton() {
const open = signal(false);
let anchor: YNode | undefined;
return (
<View>
<View ref={anchor} onPress={() => open.set(!open())} class="px-3 py-2">
<Text>More</Text>
</View>
<If when={open()}>
<Overlay
anchor={anchor}
placement="bottom"
align="start"
offset={6}
margin={8}
onPressOutside={() => open.set(false)}
onCancel={() => open.set(false)}
class="rounded-lg bg-white p-3 shadow-lg"
>
<Text>Shown above everything</Text>
</Overlay>
</If>
</View>
);
}Overlay 在 TSX 中来自 nsh,在 Rust 中 overlay() 来自 nsh_core::ui::prelude。它在视图中时就会显示:把它放进 If(Rust 中为 when(..))来打开和关闭它。后显示的浮层位于先显示的浮层之上,嵌套在另一个浮层里的浮层位于外层之上。
#放置
没有 anchor 时,浮层作为其根视图中 position: fixed 的盒子布局:它的 top、left、inset、尺寸和外边距都相对整个窗口(或对话框)。class="inset-0" 铺满整个窗口,可用作背景层或全窗口的图层;class="bottom-0 left-0 right-0" 是一条沿底部的栏。
有 anchor 时,NSH 把它放在锚点旁边:
anchor是一个视图(一个ref)、根视图坐标系中的一个Point({ x, y }),即触摸的pageX/pageY(显示在指针位置的菜单),或那里的一个Bounds({ origin, size }):输入框的光标(onCaretMove的pageBounds),输入内容的候选在那里打开。placement是它所在的一侧:"top"、"bottom"(默认)、"left"或"right"。这一侧放不下而另一侧放得下时,它会去另一侧;两侧都放不下时,它会去空间更大的一侧。align决定它沿这一侧如何对齐:"start"、"center"(默认)或"end"。在锚点上方和下方时,start 和 end 跟随布局方向:start 是左边缘,在从右到左的布局中是右边缘。offset是锚点与浮层之间的间距;margin是它与安全区边缘保持的距离,它始终位于安全区之内:放不下时会沿所在一侧滑动。
锚定的浮层会跟随锚点:锚点滚动、移动或改变尺寸时,浮层会在 NSH 布局它的同一帧里随之移动。onPosition 告诉你它放在了哪里,这样箭头可以指向锚点,较长的菜单可以限制自己的高度:
<Overlay
anchor={anchor}
onPosition={(e) => {
side.set(e.placement); // "top" when it flipped
maxHeight.set(e.available.height);
}}
/>
#关闭
浮层报告通常会关闭弹出层的操作,由你来决定是否关闭:
- 手指或鼠标按键在浮层、它的锚点以及它内部的浮层之外按下时,触发
onPressOutside。这次按压会继续传给下面的内容。 - 按下 Escape、Android 返回键或在 iOS 上使用 VoiceOver 的退出手势时触发
onCancel,只发给监听它的最上层浮层。它先于页面和BackHandler收到这些按键。
要挡住弹出层后面的屏幕,在它下面再放一个 class="inset-0"、带有关闭两者的 onPress 的浮层:Sheet 和 Popover 在手机上的呈现方式就是这样实现的。
#属性
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
anchor | .anchor(&element_ref)、.anchor_point(point)、.anchor_bounds(bounds) | YNode | Point | Bounds | null | 无 | 它相对放置的视图、点或矩形。没有时,它是根视图中的固定定位盒子。 |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | 它位于锚点的哪一侧。 |
align | .align(..) | "start" | "center" | "end" | "center" | 它沿这一侧如何与锚点对齐。 |
offset | .offset(px(..)) | number | 0 | 锚点与浮层之间的间距。 |
margin | .margin(px(..)) | number | 0 | 它与安全区边缘保持的距离。 |
children | .child(..) | JSX.Element | 无 | 它显示的内容。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 它的外观,以及没有锚点时它的位置。 |
它也接受视图的通用属性。
#事件
| 事件 | Rust | 说明 |
|---|---|---|
onPressOutside | .on_press_outside(|point| ..) | 一次按压在它、它的锚点以及它内部的浮层之外按下(pagePosition,一个 Point)。 |
onCancel | .on_cancel(|| ..) | Escape、Android 返回键或 VoiceOver 的退出手势,在它是监听这些操作的最上层浮层时。 |
onPosition | .on_position(|position| ..) | NSH 放置了它:placement(它所在的一侧)、在根视图中的 bounds,以及 available(一个 Size:这一侧的可用空间)。 |
#平台说明
- 每个平台都把浮层显示为窗口(或对话框)根视图中的原生视图,与周围的视图一样绘制和进行命中测试:UIKit、Android 视图、AppKit、GTK、Win32、GPUI 和 DOM。
- Dialog 中的浮层显示在该对话框里,位于其内容之上。
- 浮层始终在自己的窗口内:超出窗口边缘的菜单会滑回窗口之内。