元素
Resizable
可调整大小的分组把面板并排或上下排列,每两个面板的边界上有一个手柄:文档旁边的侧边栏、控制台上方的编辑器、详情旁边的列表。用户拖动手柄移动边界,每个面板都保持在应用允许的尺寸范围内。ResizablePanelGroup 负责行为:拖动(以原生方式逐帧运行)、空间如何分配、调整大小的光标,以及为手指准备的更大触摸区域。外观由应用决定:手柄是一条没有自己的线条、颜色或握把的条带,它绘制的是应用的指示器;面板是应用自己的视图。
import { mount, signal, Text, View } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
import { desktop, Log, Panel, Segmented, Split, ui, wide } from "./palette";
// The sizes are the app's: the group reports them when a drag ends, and the
// app passes them back as each panel's `size`.
function App() {
const sidebar = signal(desktop ? 220 : 140);
const consoleSize = signal(120);
const showConsole = signal<"Shown" | "Hidden">("Shown");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
const round = (sizes: number[]) => sizes.map((s) => Math.round(s)).join(" · ");
// The handles' look: a hairline that lights up under the pointer and while dragged.
const handle = "bg-border group-hover:bg-muted-foreground group-active:bg-primary";
const label = (title: string, detail: string) => (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: ui().text }}>{title}</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>{detail}</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>resizable</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Drag a line between two panels" : "Drag a line between two panels with your finger"}
</Text>
<Split
grow
width={640}
inspector={
<>
<Panel title="sizes">
<Text style={{ fontSize: 13, fontVariant: ["tabular-nums"], color: ui().text }}>
{`sidebar ${Math.round(sidebar())} · console ${Math.round(consoleSize())}`}
</Text>
</Panel>
<Segmented label="console" options={["Shown", "Hidden"] as const} value={showConsole()} onChange={(v) => showConsole.set(v)} />
<Log title="onResize" lines={events()} minHeight={96} />
</>
}
>
<View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
<ResizablePanelGroup
axis="horizontal"
handleClass={handle}
handleAccessibilityLabel="Resize the sidebar"
onResize={(sizes) => log(`row: ${round(sizes)}`)}
>
<ResizablePanel size={sidebar()} minSize={100} maxSize={320} onResize={(v) => sidebar.set(v)}>
<View style={{ flex: 1, padding: 14, gap: 10, backgroundColor: ui().inset.backgroundColor }}>
{label("Files", `${Math.round(sidebar())} pt, 100 to 320`)}
</View>
</ResizablePanel>
<ResizablePanel minSize={120}>
<ResizablePanelGroup
axis="vertical"
handleClass={handle}
handleAccessibilityLabel="Resize the console"
onResize={(sizes) => log(`column: ${round(sizes)}`)}
>
<ResizablePanel minSize={80}>
<View style={{ flex: 1, padding: 14 }}>{label("Editor", "Takes the room left")}</View>
</ResizablePanel>
<ResizablePanel
size={consoleSize()}
minSize={60}
fixed
visible={showConsole() === "Shown"}
onResize={(v) => consoleSize.set(v)}
>
<View style={{ flex: 1, padding: 14 }}>{label("Console", "Fixed: keeps its height")}</View>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{h_resizable, resizable_panel, theme};
use nsh_core::ui::prelude::*;
fn workspace() -> impl IntoElement {
h_resizable()
.handle_style(|s| s.bg(move || theme().border()))
.handle_accessibility_label("Resize the sidebar")
.on_resize(|sizes: &Vec<Points>| log::info!("sizes: {sizes:?}"))
.child(
resizable_panel()
.size(px(240.0))
.size_range(px(160.0)..px(480.0))
.p_4()
.child(text("Files").text_color(move || theme().muted_foreground())),
)
.child(
resizable_panel()
.p_4()
.child(text("Editor").text_color(move || theme().foreground())),
)
}import { mount, signal, Text } from "nsh";
import { ResizablePanel, ResizablePanelGroup } from "nsh/base";
function App() {
const sidebar = signal(240);
return (
<ResizablePanelGroup
axis="horizontal"
handleClass="bg-border group-hover:bg-ring group-active:bg-primary"
handleAccessibilityLabel="Resize the sidebar"
>
<ResizablePanel size={sidebar()} minSize={160} maxSize={480} onResize={(v) => sidebar.set(v)} class="p-4">
<Text class="text-muted-foreground">Files</Text>
</ResizablePanel>
<ResizablePanel class="p-4">
<Text class="text-foreground">Editor</Text>
</ResizablePanel>
</ResizablePanelGroup>
);
}
mount(App);ResizablePanelGroup 和 ResizablePanel 来自 nsh/base,在 Rust 中是 nsh-base crate。面板是分组的直接子元素。在 Rust 中,h_resizable() 和 v_resizable() 分别创建沿两个方向的分组(resizable_panel_group().axis(..) 把方向作为值传入),resizable_panel() 创建面板;每个属性对应一个设置方法,每个事件对应一个监听方法,外观用类型化的样式方法,内容用 .child(..);.size_range(min..max) 一次设置两个限制。
#尺寸
面板的 size 是它想要的尺寸,限制在 minSize 和 maxSize 之间。没有 size 的面板平分剩下的空间,与内容无关;所有面板都有尺寸时,多出的空间按它们的尺寸比例分配。用户拖动手柄时,分组在核心中移动面板之间的空间,途中不经过 JS:
- 放大手柄前面的面板,会从手柄后面的面板取得空间,从最近的开始,每个最多缩到它的
minSize,并跳过fixed面板。 - 把它缩到
minSize以下,会从它前面的面板取得空间。 - 任何面板都不会超过它的
maxSize,所有面板加起来也不会超过分组。
拖动结束且尺寸发生了变化时,分组按布局顺序报告每个可见面板的尺寸(分组上的 onResize),尺寸有变化的面板报告自己的尺寸(面板上的 onResize)。被拖动过的面板保持用户给出的尺寸,直到应用传入新的 size;应用把报告的尺寸作为 size 传回,就能在分组的生命周期之外保留它们,于是从存储恢复的布局会以用户离开时的尺寸打开。只有拖动和屏幕阅读器的调整才会报告:窗口调整大小、显示或隐藏面板都不会报告。
分组变大或变小时(窗口调整大小),有尺寸的面板保持尺寸,没有尺寸的面板承担差值。分组小到放不下这些尺寸时,面板会缩小(fixed 面板,如侧边栏、检查器,除外),空间恢复后再回到各自的尺寸。visible={false} 的面板不占空间:其他面板分摊它的空间,它重新显示时回到自己的尺寸。
<ResizablePanelGroup axis="vertical" handleClass="bg-border">
<ResizablePanel>
<Editor />
</ResizablePanel>
<ResizablePanel size={180} minSize={120} fixed visible={consoleOpen()}>
<Console />
</ResizablePanel>
</ResizablePanelGroup>
#手柄
每两个可见面板之间的边界上都有一个手柄:一条横跨方向 9 点粗的条带,居中于边界,带有对应方向的调整大小光标。在触摸屏上,它的触摸区域在两侧各再延伸 10 点,所以一点粗的线也很容易抓住。手柄是一个 group:它绘制的是应用的指示器,用 handleClass / handleStyle 设置样式:它横跨方向 1 点粗、与边界等长,所以只写 bg-border 在两个方向上都是一条细线,w-[3px] 或 h-[3px] 可以加粗;指示器的 group-hover: 和 group-active: 类跟随指针悬停在条带上以及按下或拖动。需要比一条线更多的内容时(一排圆点握把、一个图标),renderHandle 绘制指示器里的内容,它会得到手柄的 index、axis 和 state;状态变化时重新绘制,每次拖动只有几次,不会在每次移动时绘制。带动画的握把用手柄的 thumb(handleThumbClass / handleThumbStyle):一个居中在线上的视图,竖着绘制,在横线上转四分之一圈,所以一个类在两个方向上都适用;group-hover: 和 group-active: 类让它显示并以过渡动画变化;整个拖动过程中手柄都保持按下,无论指针移到哪里。
<ResizablePanelGroup
handleClass="w-[3px] items-center justify-center bg-border"
renderHandle={(handle) => (
<View class={handle.state === "dragging" ? "h-8 w-1 rounded-full bg-primary" : "h-6 w-1 rounded-full bg-muted-foreground"} />
)}
>
{/* 面板 */}
</ResizablePanelGroup>
disabled 让所有手柄保持不动(锁定的布局)。
#属性
#ResizablePanelGroup
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
axis | h_resizable()、v_resizable()、.axis(..) | "horizontal" | "vertical" | "horizontal" | 并排(手柄左右拖动)或上下排列(上下拖动)。 |
disabled | .disabled(..) | boolean | false | 不能拖动:手柄保持不动。 |
handleClass、handleStyle | .handle_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 每个手柄的指示器,位于手柄条带中的边界上:除非另有指定(w-[3px]、h-[3px]),它横跨方向 1 点粗、与边界等长;group-hover: 和 group-active: 跟随条带。 |
handleThumbClass、handleThumbStyle | .handle_thumb_class(..)、.handle_thumb_style(|s| ..) | string、StyleProp<ViewStyle> | 无 | 每个手柄的握把:居中在线上,按竖线的方向竖着绘制,在横线上转四分之一圈,所以一个类在两个方向上都适用。手柄悬停、按下和拖动时它始终是同一个视图,所以 group-hover: 和 group-active: 类会以过渡动画改变它(h-11 w-[3px] rounded-full bg-muted-foreground opacity-0 scale-y-0 transition group-hover:opacity-40 group-hover:scale-y-[0.45] group-active:opacity-90 group-active:scale-y-100)。 |
renderHandle | .render_handle(|handle| ..) | (handle: ResizeHandleContext) => JSX.Element | 无 | 每个指示器里的内容,手柄的 state 变化时重新绘制。 |
handleAccessibilityLabel | .handle_accessibility_label(..) | string | 无 | 屏幕阅读器为每个手柄读出的文字:它调整的是什么。屏幕阅读器每次调整 10 点。 |
children | .child(..) | JSX.Element | 无 | ResizablePanel,分组的直接子元素。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | size-full | 分组自身;除非它的类另有指定,否则填满父元素。 |
#ResizablePanel
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
size | .size(..) | number | 无 | 它想要的沿分组方向的长度(点),限制在 minSize 和 maxSize 之间;传入新值会调整它的大小。没有时它分享剩下的空间。 |
minSize | .min_size(..)、.size_range(..) | number | 100 | 它最小的尺寸。 |
maxSize | .max_size(..)、.size_range(..) | number | 不限 | 它最大的尺寸。 |
fixed | .fixed(..) | boolean | false | 分组小到放不下各面板时保持尺寸;拖动它自己的手柄仍然可以调整。需要设置 size。 |
visible | .visible(..) | boolean | true | 隐藏时不占空间,其他面板分摊它的空间。 |
children | .child(..) | JSX.Element | 无 | 它的内容。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 面板自身的表面。它沿方向的尺寸由分组决定。 |
#ResizeHandleContext
| 字段 | 类型 | 说明 |
|---|---|---|
index | number | 第 index 个可见面板之后的边界。 |
axis | "horizontal" | "vertical" | 它调整的方向。 |
state | "idle" | "hovered" | "pressed" | "dragging" | 指针或手指对它操作到了哪一步。 |
#事件
| 事件 | Rust | 说明 |
|---|---|---|
onResize(ResizablePanelGroup) | .on_resize(|sizes: &Vec<Points>| ..) | 一次拖动结束并移动了手柄,或屏幕阅读器调整了手柄:按布局顺序(从左到右、从上到下)给出每个可见面板的尺寸。 |
onResize(ResizablePanel) | .on_resize(|size: &Points| ..) | 一次拖动结束并改变了这个面板的尺寸:新的尺寸,作为它的 size 传回。 |
#部件
| 部件 | 位置 | 样式 | 状态 |
|---|---|---|---|
| 分组 | ResizablePanelGroup 的 class、style | ||
| 面板 | 分组里 | ResizablePanel 的 class、style | |
| 手柄的指示器 | 手柄条带里、边界上 | handleClass、handleStyle、renderHandle | 指针悬停时 group-hover:,按下或拖动时 group-active:;renderHandle 里的 state |
| 手柄的握把 | 居中在手柄的线上 | handleThumbClass、handleThumbStyle | group-hover:、group-active:(直到拖动结束都保持按下) |
#无障碍
- 每个手柄对屏幕阅读器来说是一个可调节的元素,
handleAccessibilityLabel是它的标签。它的值是它前面那个面板的尺寸,范围是那个面板的minSize到maxSize。 - 用 VoiceOver 在手柄上向上或向下轻扫,或使用 TalkBack 的音量键,会让它移动 10 点,与拖动效果相同;分组通过
onResize报告。disabled分组的手柄保持不动。 - 面板的内容属于应用,和其他地方一样被读出。
标签、角色和播报的一般用法见无障碍。
#平台说明
- 桌面平台(macOS、Windows、Linux、GPUI)和 Web:指针在手柄上时显示列或行的调整大小光标,用鼠标或触控板拖动即可移动边界。
- iOS、Android 和 Mac Catalyst:手指拖动手柄,触摸区域比条带两侧各宽 10 点。
- 在从右到左的布局中,面板和它们的尺寸按布局顺序排列。
- 各平台绘制的都是应用设置的样式:指示器和面板。
- Dock 的分栏就是可调整大小的分组,调整方式相同。