元素
Overlay
Overlay 會將其子項顯示在所屬根視圖(即視窗,或其外圍的對話框)內所有內容之上。父項的大小、overflow: hidden 和捲動視圖都不會裁切它。除此之外,它仍留在你撰寫它的位置:它會讀取周圍的 context,它的處理器屬於其所在的元件,並會隨父項一併移除。Popover、選單、工具提示和 Sheet 都是以它建構而成;nsh/base 已備有現成的版本(Popover、Menu、Tooltip、Sheet),而 Overlay 則用來建構你自己的版本。
import { mount, Overlay, signal, Text, View, type ViewRef } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, Segmented, 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: ViewRef | 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())}
{...button("accent", { align: "center" })}
>
<Text style={buttonText("accent")}>{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::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 ViewRef } from "nsh";
function MoreButton() {
const open = signal(false);
let anchor: ViewRef | 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>
);
}TSX 中的 Overlay 來自 nsh,Rust 中的 overlay() 則來自 nsh_core::prelude。Overlay 只要在視圖中就會顯示:將它放進 If(Rust:when(..))即可開啟和關閉。較後顯示的 Overlay 會在較早顯示的之上,而位於另一個 Overlay 內的 Overlay 則在它之上。
#位置
沒有 anchor 時,Overlay 會以其根視圖的 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是錨點與 Overlay 之間的間距;margin是它與安全區域邊緣保持的距離,它會留在安全區域之內:它會沿所在的一側滑動以適應空間。
錨點式 Overlay 會跟隨其錨點:當錨點捲動、移動或改變大小時,Overlay 會在 NSH 排版的同一個畫面影格中一併移動。onPosition 會告知它最終的位置,讓箭頭可以指向錨點,或讓較長的選單限制自己的高度:
<Overlay
anchor={anchor}
onPosition={(e) => {
side.set(e.placement); // "top" when it flipped
maxHeight.set(e.available.height);
}}
/>
#關閉
Overlay 會回報通常用來關閉彈出視窗的操作,並由你決定如何處理:
- 當手指或滑鼠按鍵在 Overlay、它的錨點及其內部的 Overlay 之外按下時,會觸發
onPressOutside。該次按下會繼續傳給其下方的內容。 - 按下 Escape、Android 返回按鈕,以及 iOS 上 VoiceOver 的退出手勢時,會觸發
onCancel,而且只限最上層並監聽此事件的 Overlay。它會先於頁面和BackHandler接收這些操作。
若要封鎖彈出視窗後面的畫面,可在其下方放置第二個帶有 class="inset-0" 的 Overlay,並使用會同時關閉兩者的 onPress:Sheet 和 Popover 的手機版呈現方式就是這樣運作。
#屬性
| 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
anchor |
.anchor(&element_ref)、.anchor_point(point)、.anchor_bounds(bounds) |
ViewRef | Point | Bounds | null |
無 | 用作定位基準的視圖、點或方塊。沒有錨點時,它是其根視圖中的固定方塊。 |
placement |
.placement(..) |
"top" | "bottom" | "left" | "right" |
"bottom" |
它所在的錨點一側。 |
align |
.align(..) |
"start" | "center" | "end" |
"center" |
它沿該側如何與錨點對齊。 |
offset |
.offset(px(..)) |
number |
0 |
錨點與 Overlay 之間的間距。 |
margin |
.margin(px(..)) |
number |
0 |
它與安全區域邊緣保持的距離。 |
children |
.child(..) |
JSX.Element |
無 | 它所顯示的內容。 |
class、style |
樣式方法 | string、StyleProp<ViewStyle> |
無 | 它的外觀,以及在沒有錨點時它所在的位置。 |
它也支援視圖的通用屬性。
#事件
| 事件 | Rust | 說明 |
|---|---|---|
onPressOutside |
.on_press_outside(|point| ..) |
在它、它的錨點及其內部的 Overlay 之外有按下操作(pagePosition,一個 Point)。 |
onCancel |
.on_cancel(|| ..) |
按下 Escape、Android 返回按鈕或 VoiceOver 的退出手勢,而它是正在監聽的最上層 Overlay。 |
onPosition |
.on_position(|position| ..) |
NSH 已完成它的定位:placement(它所在的一側)、根視圖中的 bounds,以及 available(一個 Size:該側的可用空間)。 |
#平台說明
- 各平台都會將 Overlay 顯示為視窗(或對話框)根視圖的原生視圖,其繪製和點擊測試方式與周圍的視圖相同:UIKit、Android views、AppKit、GTK、Win32、GPUI 和 DOM。
- Dialog 內的 Overlay 會顯示在該對話框中,位於其內容之上。
- Overlay 會留在所屬視窗內:原本會伸出視窗邊緣的選單,會滑回視窗之內。