ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

gpui-kit 可拖拽分栏布局指南:Resizable 面板组与拖拽手柄源码级剖析

gpui-kit 可拖拽分栏布局指南:Resizable 面板组与拖拽手柄源码级剖析 gpui-kit 可拖拽分栏布局指南Resizable 面板组与拖拽手柄源码级剖析【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit导读本指南以 gpui-kit 的gpui-base原语文档为基础系统讲解 Resizable 模块——一套用于构建用户可拖拽调整的分栏split布局的面板组与拖拽手柄原语。你将掌握h_resizable/v_resizable的声明式组合方式、ResizableState的状态管理模型、拖拽过程中的尺寸约束算法以及如何将手柄外观接入主题体系。读完即可在你的 GPUI 桌面应用中复刻侧边栏、代码面板、工作区等常见可调布局。一、Resizable 是什么Resizable 是gpui-base提供的一组原语用于构建用户可拖拽调整的分栏布局user-adjustable split layouts。与gpui-base的其他原语一致Resizable只供应行为与语义结构不施加任何产品视觉语言——面板与手柄的呈现完全交由 GPUI 标准样式Styled、事件 trait与消费方自己的设计系统决定。它的职责边界非常清晰见 crates/base/src/resizable/mod.rs面板尺寸存在哪里所有面板的当前尺寸集中存放在ResizableState中拖拽由谁驱动拖拽手柄resize handle负责捕获鼠标交互并将位移换算成相邻面板的尺寸变化结构由谁提供ResizablePanelGroup提供 flex 容器与面板同步逻辑ResizablePanel提供单个面板的布局约束。一句话概括其设计哲学GPUI 的标准样式与事件机制负责看起来怎样、怎么响应Resizable 类型负责交互结构怎样组织。二、快速运行示例原文档给出的运行命令可以直接从仓库根目录启动原生可运行示例WASM 预览与它共享同一份实现cargo run -p gpui-base-examples -- resizable命令背后的入口是 crates/base/examples/native/src/bin/components.rs#[path ../../../showcase/mod.rs] mod showcase; use std::sync::Arc; fn main() { let component std::env::args() .nth(1) .unwrap_or_else(|| overview.to_string()); let http_client reqwest_client::ReqwestClient::user_agent(gpui-base/examples).unwrap(); let app gpui_platform::application().with_http_client(Arc::new(http_client)); showcase::run(app, component); }命令行第一个参数resizable即被传给showcase::run从共享的 showcase 实现中选出本原语。同一份 showcase 还会编译到 WASM 预览中因此原生与浏览器两种运行方式看到的是完全相同的代码路径。三、导入方式原文档给出的导入路径经由聚合 crateuse gpui_kit::base::{ResizablePanel, ResizablePanelGroup, ResizableState, h_resizable, resizable_panel};而示例代码crates/base/examples/showcase/components/resizable.rs直接使用gpui_basecrateuse gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel};两种方式导出的是同一组符号。gpui-base在 crates/base/src/lib.rs 中公开了完整的面板组 API#[doc(hidden)] pub use resizable::{PANEL_MIN_SIZE, resize_handle}; pub use resizable::{ ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, ResizeHandleContext, ResizeHandleRenderer, h_resizable, resizable_panel, v_resizable, };其中resize_handle被标记为#[doc(hidden)]内部实现细节面板组会自动为每个面板装配ResizeHandleRenderer/ResizeHandleContext则用于自定义手柄外观。四、Anatomy三个核心类型的分工原文档指出示例由三个类型组合而成ResizablePanel、ResizablePanelGroup、ResizableState。对应源码位于 crates/base/src/resizable/panel.rs 与 crates/base/src/resizable/mod.rs三者关系如下类型文件职责ResizablePanelGrouppanel.rsflex 容器持有面板列表、轴方向、可选外部状态实体、on_resize回调与手柄外观ResizablePanelpanel.rs单个面板初始尺寸、尺寸范围、可见性、子内容与样式覆盖ResizableStatemod.rs全部面板尺寸的单一事实来源执行拖拽时的尺寸重分配算法4.1 组h_resizable与v_resizableResizablePanelGroup通过轴方向Axis决定布局方向。原语提供了两个便捷构造函数mod.rs/// Create a [ResizablePanelGroup] with horizontal resizing pub fn h_resizable(id: impl IntoElementId) - ResizablePanelGroup { ResizablePanelGroup::new(id).axis(Axis::Horizontal) } /// Create a [ResizablePanelGroup] with vertical resizing pub fn v_resizable(id: impl IntoElementId) - ResizablePanelGroup { ResizablePanelGroup::new(id).axis(Axis::Vertical) }注意id是必需的面板组用它做稳定元素 ID内部use_keyed_state依赖它见下文状态一节水平分组h_resizable代表面板左右并排、分隔线可左右拖动垂直分组v_resizable代表面板上下堆叠、分隔线可上下拖动构造器默认为Axis::Horizontal也可直接ResizablePanelGroup::new(id).axis(...)自定义。4.2 面板resizable_panel()/// Create a [ResizablePanel]. pub fn resizable_panel() - ResizablePanel { ResizablePanel::new() }面板构造后通常通过.child(...)填充内容ResizablePanel实现了ParentElement。它自身实现了Styled因此调用方可以覆盖面板的渲染样式。4.3 一个最小结构一个标准的双面板布局侧边栏 内容区长这样h_resizable(example-resizable) .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child(sidebar_content), ) .child( resizable_panel() .child(workspace_content), )五、完整 Rust 示例来自可运行 showcaseshowcase 中本原语的完整实现crates/base/examples/showcase/components/resizable.rs被原文档直接嵌入它模拟了一个导航栏 工作区的经典场景是理解 API 组合方式的最佳范本use gpui::{IntoElement, ParentElement as _, Styled as _, div, px}; use gpui_base::{h_resizable, resizable_panel}; use super::super::BaseShowcase; impl BaseShowcase { pub(in super::super) fn resizable(self) - impl IntoElement { div() .w_72() .h_40() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .child( h_resizable(example-resizable) .child( resizable_panel() .size(px(124.)) .size_range(px(116.)..px(210.)) .child( div() .size_full() .flex() .items_center() .justify_center() .border_r_1() .border_color(super::example_rgb(0x171717)) .p_2() .items_start() .justify_start() .flex_col() .gap_1() .child( div() .text_xs() .text_color(super::example_rgb(0x737373)) .child(PROJECT), ) .children([Overview, Components, Settings].map( |label| { div() .w_full() .h(px(26.)) .px_2() .flex() .items_center() .whitespace_nowrap() .child(label) }, )), ), ) .child( resizable_panel().child( div() .size_full() .flex() .items_center() .justify_center() .bg(super::example_rgb(0xffffff)) .p_2() .items_start() .justify_start() .flex_col() .gap_2() .child(div().child(Workspace)) .child( div() .text_color(super::example_rgb(0x737373)) .child(Drag the divider to resize navigation.), ), ), ), ) } }值得注意的细节.size(px(124.))左侧面板的初始尺寸.size_range(px(116.)..px(210.))限定拖拽时该面板可处于的尺寸区间116px ~ 210pxh_resizable(example-resizable)组需要一个稳定的元素 ID用于内部状态键控右侧面板未指定size走自动/灵活路径占据剩余空间div().size_full()保证每个面板的内容填满自身边界便于观察拖拽效果。命令cargo run -p gpui-base-examples -- resizable提供应用初始化、窗口创建与共享的BaseShowcase状态上述代码即是其渲染函数的核心。六、状态与事件尺寸的单一事实来源原文档反复强调一点面板尺寸存于 resizable 状态中拖拽手柄会按照面板最小值约束来更新相邻面板。这一点在源码中有完整的落点。6.1ResizableState的内部结构#[derive(Debug, Clone)] pub struct ResizableState { axis: Axis, // 与所在组的实际轴同步 panels: VecResizablePanelState, // 每面板的状态size/size_range/bounds sizes: VecPixels, // 每面板的当前尺寸 resizing_panel_ix: Optionusize, // 当前正在拖拽的手柄位于面板 ix 与其右侧之间 bounds: BoundsPixels, // 组的边界用于计算容器尺寸 }其中每个面板的状态ResizablePanelState还记录了size: OptionPixels—— 面板偏好尺寸None表示由 flex 自动布局size_range: RangePixels—— 尺寸约束区间默认PANEL_MIN_SIZE..Pixels::MAXbounds—— 最近一次 prepaint 测量到的边界。模块级默认最小尺寸mod.rs#[doc(hidden)] pub const PANEL_MIN_SIZE: Pixels px(100.);全局默认最小面板宽度/高度为 100px面板未显式指定size_range时即以此为下限。6.2 两种持有状态的方式ResizablePanelGroup在RenderOnce::render中解析状态panel.rslet state self.state.unwrap_or( window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default()), );内部状态默认不传with_state时组通过use_keyed_state以组的id为键自建状态随渲染生命周期自动管理外部受控状态通过.with_state(entity)把调用方持有的EntityResizableState绑定到组上panel.rs/// Bind yourself to a resizable state entity. /// /// If not provided, it will handle its own state internally. pub fn with_state(mut self, state: EntityResizableState) - Self { self.state Some(state.clone()); self }原文档对此给出的实践建议是把受控状态放在父渲染类型或 GPUI entity 上在回调中更新它并调用cx.notify()不要在每次渲染时重建持久实体。这正是 dock 等复杂容器采用的做法ResizableState::adopt_sizes即专供 dock 的 pane 树采纳外部布局决策。6.3 状态提供的操作ResizableState面向调用方公开了一组编程式操作mod.rs全部走与拖拽相同的重分配逻辑因此程序化调整与用户拖拽行为完全一致方法行为sizes()返回各面板当前尺寸快照VecPixelsresize_panel(ix, size, ...)将面板ix调整为size超出size_range时自动夹紧最后一个面板没有自己的手柄通过调整其前一个兄弟面板间接改变尺寸L69-L89insert_panel(size, ix, ...)在ix处插入面板缺省追加并按比例压缩其余面板保证总和仍等于容器尺寸L95-L128remove_panel(ix, ...)移除面板并重分配剩余空间L221-L230reset_panel(ix, ...)重置面板状态但保留当前尺寸L233-L239clear()清空全部面板状态L242-L245其中resize_panel的边界情况值得一提对于最后一个面板源码通过先改前一个兄弟、让释放的空间落到最后一个的方式驱动L79-L87if ix 1 self.sizes.len() { self.resize_panel_at_handle(ix, size, window, cx); } else if ix 0 { // Last panel: drive its size by resizing the previous sibling so // the freed space lands here. let delta self.sizes[ix] - size; let prev self.sizes[ix - 1]; self.resize_panel_at_handle(ix - 1, prev delta, window, cx); }6.4 事件Resized与on_resize状态实现EventEmitterResizablePanelEventmod.rs事件定义在 panel.rspub enum ResizablePanelEvent { Resized, }拖拽结束鼠标抬起时done_resizing清除resizing_panel_ix并发出Resized事件mod.rs偏好持久化等订阅方可以借此感知用户刚完成一次拖拽组层面还提供on_resize回调panel.rs参数为(EntityResizableState, mut Window, mut App)同样在鼠标抬起时触发见 panel.rs 的MouseUpEvent处理。测试 dragging_the_handle_resizes_and_emits_once 验证了一次完整拖拽只触发一次 resize 回调这一语义模拟鼠标按下、移动、抬起后断言resizes.get() 1。七、面板组与面板的 API 详解7.1 组的可配置项方法说明axis(Axis)布局轴默认Horizontalchild(panel)/children(panels)添加一个或多个面板size(px)设置组的交叉轴尺寸水平分组时它是组的高度垂直分组时它是组的宽度L97-L104。组的自身轴尺寸始终为size_full()由容器决定with_state(EntityResizableState)绑定外部受控状态with_handle_appearance(renderer)为组内所有手柄指定绘制器见第八节on_resize(callback)拖拽结束回调测试 a_group_size_binds_the_cross_axis 验证了size()的交叉轴语义对水平分组调用.size(px(40.))后面板测量结果为 400px 宽 × 40px 高。7.2 面板的可配置项方法说明size(px)初始尺寸未指定时面板走 flex 自动布局size_range(range)尺寸约束区间默认px(100.)..Pixels::MAXvisible(bool)面板可见性默认true不可见时渲染为空divL301-L305Styled覆盖面板内部默认flex_grow: 1调用方可通过.flex_none()等取消并自由添加 padding/颜色/边框7.3 保留样式不要从外部调用的 API面板的尺寸管理依赖一组内部样式调用方不应覆盖否则会与面板自身的布局管理冲突panel.rs 的文档注释明确列出.flex_basis(...)—— 由ResizableState驱动不由调用方决定.absolute()—— 会把面板从 resizable 的 flex 流中移除.overflow_hidden()—— 可能裁掉拖拽手柄手柄以left: -4px绝对定位在每个非首面板的左缘。一个常见且推荐的覆盖是.flex_none()面板内部无条件设置flex_grow: 1因此当兄弟面板收缩时一个指定了尺寸的面板若想保持自身宽度就必须通过.flex_none()退出增长。源码文档注释给出了典型的三栏用法panel.rsh_resizable(layout) .child(resizable_panel().size(px(220.)).flex_none().child(sidebar)) .child(resizable_panel().child(content)) // flex .child(resizable_panel().size(px(280.)).flex_none().child(metadata))7.4 面板的尺寸计算顺序ResizablePanel::render中的样式组装顺序panel.rs清晰展示了三种情况initial_size为None→ 自动尺寸flex_grow_1flex_shrink_1由 flex 布局分摊空间initial_size为Some且状态中尺寸为None首次渲染→flex_noneflex_basis(initial_size)按初始尺寸呈现状态中已有Some(size)→flex_basis(size.clamp(range.start, range.end))完全由状态驱动。每次 prepaint 时update_panel_size会把测量到的真实边界与尺寸范围回写进状态mod.rs其中有个细节当某面板尺寸仍等于PANEL_MIN_SIZE即尚未被拖过的新面板时会直接采用测量尺寸避免首帧自行动作。八、拖拽手柄命中区、光标与外观手柄完全由面板组自动装配每个非首面板的左缘水平或上缘垂直都会自动挂载一个resize_handlepanel.rs。8.1 命中区与光标手柄的几何参数定义在 resize_handle.rspub(crate) const HANDLE_PADDING: Pixels px(4.); pub(crate) const HANDLE_SIZE: Pixels px(1.);可见线宽只有1px但手柄盒在轴线两侧各向外扩展4px的 padding形成9px 宽的命中区left: -4px绝对定位便于鼠标抓取水平分组使用cursor_col_resize左右拉伸光标垂直分组使用cursor_row_resize上下拉伸光标手柄实现了group(handle)内置线条在 hover 时保持可辨识。8.2 拖拽的底层算法resize_panel_at_handle真正执行尺寸重分配的是 resize_panel_at_handle鼠标移动事件与编程式resize_panel共用。核心逻辑计算期望位移move_changed 目标尺寸 - 当前尺寸将目标尺寸按面板size_range夹紧展开变大时多余空间按顺序从右侧兄弟面板扣除每个兄弟最多扣到自身size_range.start最小值扣不完则继续向更右侧面板借收缩变小时被压缩的空间从左侧兄弟面板补还若总尺寸超出容器则对主面板继续夹紧保证任何时刻所有面板总和不超过容器。鼠标移动事件在ResizePanelGroupElement中注册panel.rs水平分组下用鼠标位置.x - 面板左缘计算目标宽度垂直分组对应使用 y 坐标鼠标抬起时结束拖拽并触发on_resize。这正印证了原文档的描述dragging handles updates adjacent panels subject to minimums——拖拽更新相邻面板且始终受最小值约束。九、手柄主题化ResizeHandleRenderer与ResizableTheme9.1 用with_handle_appearance接管手柄绘制组的with_handle_appearancepanel.rs接受一个ResizeHandleRenderer其签名resize_handle.rspub type ResizeHandleRenderer Rcdyn Fn(ResizeHandleContext, mut Window, mut App) - OptionAnyElement;命中区、光标与拖拽行为始终留在手柄本体渲染器只负责手柄内部画什么渲染器返回None时回落到内置 1px 线条——因此可以只覆盖部分手柄其余保留默认源码注释明确a renderer that declines — or is absent — leaves the built-in lineResizeHandleContext暴露axis()拖拽轴与is_active()当前是否正在被拖拽供渲染器区分状态。9.2 内置线条的颜色解析handle_color未自定义外观时内置线条的颜色由handle_color决定resize_handle.rspub(crate) fn handle_color(theme: crate::Theme, active: bool) - gpui::Hsla { if active { theme .resizable .active_handle .unwrap_or(theme.tokens.colors.ring) } else { theme.resizable.handle.unwrap_or(theme.tokens.colors.border) } }即优先使用主题中投影到resizable上的颜色未投影时回落到全局语义 token——静止态用border拖拽激活态用ring。这解决了历史问题此前未投影时默认是透明色导致没有样式覆盖的消费方看不到分隔线相关回归测试见 resize_handle.rs 测试且专门断言了默认色不再透明。对应的主题字段定义在 crates/base/src/theme.rs#[derive(Clone, Copy, Default)] pub struct ResizableTheme { pub handle: Optiongpui::Hsla, pub active_handle: Optiongpui::Hsla, }十、源码测试印证的行为契约ResizableState的测试模块mod.rs 测试是本原语行为契约最权威的说明可重点参考dragging_the_handle_resizes_and_emits_once模拟在400px容器中把 150px/250px 的面板拖到 220px断言最终尺寸为 220px/180px 且on_resize恰好触发一次group_measures_panels_and_programmatic_resize_uses_drag_rules编程式resize_panel(0, px(220.))与拖拽走同一套规则得到相同结果dynamic_panel_lifecycle_is_owned_by_resizable_state验证insert_panel后 200px/200px 均分、remove_panel后回到 400px、clear后清空a_group_size_binds_the_cross_axis验证组size()只约束交叉轴mixed_sizing_is_stable_between_resize_and_followup_frame与caller_owned_state_settles_on_the_same_frame验证固定尺寸面板 灵活面板混排时容器变化后的比例缩放在下一帧不会再次移动分隔线内部通过window.defer延迟通知让落定帧立即调度见 panel.rs 的注释。十一、可访问性与使用注意事项原文档在 Accessibility 与 Notes 两节给出了几条对消费方设计的硬性要求结合源码可进一步明确落点为手柄提供键盘替代方案手柄本身只有鼠标命中区与光标cursor_col_resize/cursor_row_resize没有内置键盘交互。消费方需要自行提供键盘可达的调节手段——ResizableState::resize_panel正是为此准备的编程式入口且会照常发出Resized事件行为与用户拖拽完全一致保留可用的最小面板尺寸拖拽算法对每个面板按size_range下限夹紧默认PANEL_MIN_SIZE px(100.)消费方应确保自己设置的下限对内容依然可用使用稳定的元素 IDh_resizable/v_resizable的id是内部use_keyed_state的键。保持 ID 稳定可避免状态在重渲染间丢失在需要跨帧稳定的场景如 dock应使用外部with_state实体而不是在每次渲染中重建在消费方设计系统中核验各状态外观hover、activeResizeHandleContext::is_active、以及 reduced-motion、high-contrast 等系统偏好下的呈现属于消费方设计系统的责任原语不做强制。十二、总结gpui-kit 的 Resizable 原语是一套行为完备、外观可塑的分栏布局基础设施声明式组合h_resizable/v_resizableresizable_panel()用几行代码即可搭出可拖拽面板组状态单一来源ResizableState集中管理尺寸拖拽与编程式调整共享同一套resize_panel_at_handle约束算法最小值夹紧、相邻面板重分配、容器不溢出外观完全可定制面板样式可经Styled覆盖手柄绘制可经ResizeHandleRenderer接管主题色经ResizableTheme投影并回落border/ringtoken契约有测试背书从拖拽事件触发次数、混排稳定性到生命周期管理行为均有#[gpui::test]佐证可在 crates/base/src/resizable/mod.rs 与 crates/base/src/resizable/panel.rs 中直接查阅。无论你要实现的是编辑器侧边栏、属性面板还是 dock 式多窗格工作区这套原语都能在提供完整交互语义的同时把视觉呈现的最终决定权留给你的设计系统。【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表