gpui-kit 滚动条 Scrollbar 深度指南:为 GPUI 滚动视图定制带动效的自绘滚动条
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
Scrollbar是 gpui-kit 的gpui-base基础库中提供的一个自绘(custom-painted)滚动条组件,它连接 GPUI 的滚动句柄(scroll handle),为滚动区域、列表以及自定义视口补充可交互、可定制、可动效化的滚动条体验。它支持垂直、水平与双轴视口、轨道点击跳转、滑块拖拽、可配置的显示模式、类型化画笔样式、减少动态效果(reduced motion)偏好,以及可反向的可见性与宽度过渡。读完本文,你将掌握在 GPUI 应用中接入Scrollbar、配置全局主题与单实例样式、接入自定义滚动容器,以及理解其交互与过渡生命周期的完整方法。
架构分工:Base 拥有生命周期,应用层拥有表现
gpui-base(crates/base)拥有Scrollbar的交互与过渡生命周期:可见性状态机、轨道点击、滑块拖拽、滚动监听、idle 计时与动画采样都由 Base 内部实现。而颜色、几何、时序与入场编排(entrance choreography)属于你的应用层或设计系统层——通过全局ScrollbarTheme或单实例.styles(...)注入。Base 自身不携带任何产品动效:ScrollbarMotion::default()的enter、exit、expand时长全部为零,只有 2 秒的行为性 idle 保持;未安装动效的应用会得到即时显现与即时变宽的滚动条。
该分工在源码中体现得非常清晰:ScrollbarMotion的文档注释明确写道"Base installs no motion of its own: every transition duration defaults to zero",而ScrollbarEntrance的注释则是"The styled layer chooses the choreography; Base only plays it"(见 crates/base/src/scrollbar.rs)。Base 只提供机制(mechanism),产品节奏(timing)与入场风格属于你的设计系统。
运行示例
原生 showcase 与 WASM 预览使用同一套实现,可直接运行:
cargo run -p gpui-base-examples -- scrollbar完整的可运行示例源码位于 crates/base/examples/showcase/components/scrollbar.rs,它渲染一个 20 行的活动列表,并叠加一个ScrollbarMode::Always模式的双轴滚动条:
impl BaseShowcase { pub(in super::super) fn scrollbar(&self) -> impl IntoElement { div() .id("example-scroll-region") .relative() .w_72() .h_48() .text_xs() .border_1() .border_color(super::example_rgb(0x171717)) .overflow_scroll() .track_scroll(&self.example_scroll) .child(div().children((1..=20).map(|row| { div() .h_7() .px_2() .flex() .items_center() .border_b_1() .border_color(super::example_rgb(0xe5e7eb)) .justify_between() .child(format!("Activity {row}")) .child(if row % 3 == 0 { "Completed" } else { "Pending" }) }))) .child(Scrollbar::new(&self.example_scroll).mode(ScrollbarMode::Always)) } }导入与基础用法
在组件中使用Scrollbar需要引入以下符号(示例见 website/base/primitives/scrollbar.md):
use std::time::Duration; use gpui_kit::{div, px, rgb, ScrollHandle, Styled as _}; use gpui_kit::base::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarMode, ScrollbarMotion, ScrollbarStyles, ScrollbarTheme, Theme, };核心使用模式有三步:
- 把
ScrollHandle保存在持久的视图状态上(例如结构体字段),保证跨渲染保持稳定; - 用
track_scroll(&self.scroll_handle)把句柄挂到可滚动内容上; - 在同一相对容器内叠加一个
Scrollbar。
pub struct ActivityList { scroll_handle: ScrollHandle, } impl ActivityList { pub fn new() -> Self { Self { scroll_handle: ScrollHandle::new(), } } fn render_list(&self) -> impl gpui_kit::IntoElement { div() .relative() .size_full() .overflow_scroll() .track_scroll(&self.scroll_handle) .child(div().children((1..=100).map(|row| { div().h_8().px_2().child(format!("Activity {row}")) }))) .child(Scrollbar::new(&self.scroll_handle)) } }Scrollbar::new默认启用双轴(ScrollbarAxis::Both)。当容器只在一个方向上滚动时,应使用轴专用构造器,或显式指定轴:
Scrollbar::vertical(&scroll_handle); Scrollbar::horizontal(&scroll_handle); Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical);在源码中,Scrollbar::vertical与Scrollbar::horizontal只是Scrollbar::new加上.axis(...)的语法糖(见 crates/base/src/scrollbar.rs)。
滚动条是绝对定位覆盖层。其布局与命中盒(hitbox)在布局阶段固定不动,只有绘制的轨道和滑块在动画——因此入场动效不会移动内容,也不会改变交互几何。这一点在request_layout中实现:滚动条元素使用position: Absolute、flex_grow: 1、宽高均为relative(1.)填满容器(见 crates/base/src/scrollbar.rs)。
可见性模式(Visibility Modes)
可以给单个滚动条设置模式,也可以省略.mode(...)以使用全局ScrollbarTheme中的模式:
Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Scrolling); Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Hover); Scrollbar::vertical(&scroll_handle).mode(ScrollbarMode::Always);| 模式 | 行为 |
|---|---|
Scrolling | 滚动或拖拽后出现。已可见的滚动条在悬停期间保持可见;离开后开始一次全新的 idle 保持。悬停无法唤出完全隐藏的滚动条。 |
Hover | 指针进入滚动条轨道时出现。 |
Always | 保持可见,并跳过可见性过渡。 |
三种模式对应的判断逻辑集中在wants_visible、tracks_thumb_hover与hover_keeps_visible三个纯函数中(见 crates/base/src/scrollbar.rs),并有对应的单元测试覆盖(如hidden_scrolling_mode_does_not_track_thumb_hover、visible_scrolling_mode_stays_visible_while_hovered)。Scrolling模式下,隐藏的滑块不会保留"潜在悬停状态",因此不会在下次滚动时意外膨胀。
宽度约定:所有模式默认使用 6 px 的静息滑块宽度;轨道悬停保持该宽度;滑块悬停与激活拖拽瞄准 8 px 的活动宽度。宽度变化使用配置的expand时长。这些默认值来自源码常量:THUMB_WIDTH = px(6.)、THUMB_ACTIVE_WIDTH = px(8.)、THUMB_INSET = px(4.),而滚动条整体轨道宽度WIDTH = THUMB_ACTIVE_INSET * 2 + THUMB_ACTIVE_WIDTH = px(16.)(见 crates/base/src/scrollbar.rs)。测试every_mode_expands_only_for_thumb_hover验证了三种模式都是"正常/轨道悬停 6 px、滑块悬停 8 px"的扩张规则。
隐藏状态下忽略交互:隐藏的轨道与滑块点击被忽略。prepaint阶段只有当滚动条可见(is_visible)时才注册MouseDownEvent处理(见 crates/base/src/scrollbar.rs),测试hidden_hover_scrollbar_ignores_track_click与hidden_hover_scrollbar_ignores_thumb_drag分别验证了这两种情况。
配置全局主题
ScrollbarTheme使用私有字段搭配消费型构建器(consuming builder)与读取器。在应用初始化时或设计系统主题切换时设置它:
fn install_scrollbar_theme(cx: &mut gpui_kit::App) { let styles = ScrollbarStyles::default() .track(|style| { style .width(px(16.)) .bg(rgb(0x000000).alpha(0.08)) }) .track_hover(|style| { style.bg(rgb(0x000000).alpha(0.12)) }) .track_active(|style| { style.bg(rgb(0x000000).alpha(0.16)) }) .thumb(|style| { style .width(px(6.)) .inset(px(4.)) .radius(px(3.)) .min_length(px(48.)) .bg(rgb(0x737373)) }) .thumb_hover(|style| { style.width(px(8.)).bg(rgb(0x525252)) }) .thumb_active(|style| { style.width(px(8.)).bg(rgb(0x404040)) }); let motion = ScrollbarMotion::default() .with_idle(Duration::from_secs(2)) .with_enter(Duration::from_millis(300)) .with_exit(Duration::from_millis(500)) .with_expand(Duration::from_millis(300)) .with_entrance(ScrollbarEntrance::Fade) .with_thumb_hover_entrance(ScrollbarEntrance::SlideAndFade); Theme::global_mut(cx).scrollbar = ScrollbarTheme::new() .with_mode(ScrollbarMode::Scrolling) .with_motion(motion) .with_styles(styles); }同样的值可以在不暴露主题字段的情况下读取:
let scrollbar = &Theme::global(cx).scrollbar; let mode = scrollbar.mode(); let motion = scrollbar.motion(); let styles = scrollbar.styles();ScrollbarTheme定义在 crates/base/src/theme.rs,它把mode、motion、styles三个维度打包为一个可整体换入的全局默认值,并通过Theme::global_mut(cx).scrollbar = ...挂到全局主题上。
运动令牌(ScrollbarMotion)
ScrollbarMotion是滚动条的全部时序与入场配置,字段与默认值如下(见 crates/base/src/scrollbar.rs):
| 构建器方法 | 含义 | 默认值 |
|---|---|---|
with_idle(Duration) | 最后一次滚动/拖拽/悬停后保持可见的时长 | 2s(行为性保持,DEFAULT_IDLE) |
with_enter(Duration) | 变为完全可见所需时长 | Duration::ZERO |
with_exit(Duration) | idle 到期后淡出所需时长 | Duration::ZERO |
with_expand(Duration) | 滑块到达新宽度所需时长 | Duration::ZERO |
with_entrance(ScrollbarEntrance) | 整体入场编排 | Fade |
with_thumb_hover_entrance(ScrollbarEntrance) | 滑块悬停唤起时的入场编排 | Fade |
Base 不安装任何产品动效:ScrollbarMotion::default()使用 2 秒的行为性 idle 保持,但enter、exit、expand都是零时长。未安装动效的应用因此获得即时的可见性与宽度变化。单元测试base_ships_no_motion_of_its_own专门锁定这一契约,并注释说明 idle 是"行为而非动效",必须保留以保证Scrolling模式可用。
动效行为
上文示例主题会产出如下编排:
| 触发条件 | 入场方式 |
|---|---|
在Scrolling或Hover模式下滚动 | entrance:原地淡入 |
Hover模式下轨道悬停 | entrance:原地淡入 |
Hover模式下滑块悬停 | thumb_hover_entrance:从最近边缘滑入并淡出 |
Always模式 | 立即显示;跳过可见性动效 |
entrance_for的逻辑是:仅当模式为Hover且滑块被悬停时才使用thumb_hover_entrance,否则一律使用entrance(见 crates/base/src/scrollbar.rs),并有测试hover_mode_slides_only_when_the_thumb_is_hovered验证。
SlideAndFade 的方向:垂直滚动条从右侧进入,水平滚动条从底部进入。这由visibility_translation实现——垂直轴沿 x 方向平移轨道宽度,水平轴沿 y 方向平移(见 crates/base/src/scrollbar.rs)。
缓动曲线:透明度在入场时使用线性进度,在退出时使用ease_in_cubic;位置在入场时使用ease_out_cubic,退出时使用ease_in_cubic(见VisibilityAnimation::sample,crates/base/src/scrollbar.rs)。测试entrance_fades_linearly_while_position_eases_out验证了"半程时透明度恰为 0.5 而位置大于 0.5"的 ease-out 特征;fade_entrance_snaps_position_and_animates_opacity则验证 Fade 入场位置立即到位、仅透明度动画。
中断与方向反转:被打断的过渡会先采样当前的透明度与位置,再改变方向,且过渡时长按剩余距离缩放,保证速度不突变(见set_visible,crates/base/src/scrollbar.rs)。测试visibility_animation_reverses_from_current_progress与idle_boundary_starts_the_exit_without_a_jump覆盖了这两种场景。
零时长即到目标:零时长直接采用目标值,即使有过渡正在进行也一样(ScalarTransition::settle与set_visible中的full_duration.is_zero()分支)。测试a_zero_duration_settles_a_transition_already_in_flight模拟"入场中途开启 reduced motion"的切换场景。
减少动态效果:GPUI 的reduce_motion偏好会把可见性与宽度时长都置零,因此无需单独的 reduced-motion 主题。在prepaint中:
let reduce_motion = cx.reduce_motion(); let (enter, exit) = if !mode.is_always() && !reduce_motion { (motion.enter(), motion.exit()) } else { (Duration::ZERO, Duration::ZERO) }; let expand = if reduce_motion { Duration::ZERO } else { motion.expand() };(见 crates/base/src/scrollbar.rs)。也就是说:Always模式跳过可见性动效但保留宽度动画;reduced motion 则把所有通道都变为立即生效。测试motionless_base_snaps_every_transition与reduced_motion_snaps_thumb_expansion分别锁定了这两种行为。
单实例样式覆盖
使用.styles(...)覆盖单个滚动条的全局样式。实例样式优先于主题默认值:
Scrollbar::vertical(&scroll_handle).styles(|styles| { styles .track(|style| style.width(px(14.)).bg(rgb(0xf5f5f5))) .track_hover(|style| style.bg(rgb(0xe5e5e5))) .thumb(|style| { style .width(px(6.)) .inset(px(3.)) .radius(px(3.)) .min_length(px(40.)) .bg(rgb(0x737373)) }) .thumb_hover(|style| style.width(px(8.)).bg(rgb(0x525252))) .thumb_active(|style| style.width(px(8.)).bg(rgb(0x404040))) })两类样式结构体支持以下字段(定义见 crates/base/src/scrollbar.rs):
| 样式结构 | 支持字段 | 说明 |
|---|---|---|
ScrollbarTrackStyle | bg、border_color、width | 轨道背景、边框色与轨道宽度 |
ScrollbarThumbStyle | bg、width、inset、radius、min_length | 滑块背景、宽度、内缩、圆角与最小长度 |
样式级联顺序(见resolve_track/resolve_thumb,crates/base/src/scrollbar.rs)从高到低为:
- 当前状态样式(active / hovered 等状态专用);
- 实例
.styles(...)中对应状态的值; - 全局
ScrollbarTheme样式; - 由主题派生或内置的默认值(
MIN_THUMB_SIZE = px(48.)兜底min_length)。
主题派生的滑块底色:未显式覆盖时,滑块默认色取自当前主题的tokens.colors.foreground并按状态施加透明度(normal 0.35、hover/active 0.55),而不是写死的黑色——这样浅色/深色主题切换时滑块始终可见(见thumb_default_background与style_for_normal,crates/base/src/scrollbar.rs)。测试unstyled_thumb_follows_the_theme_rather_than_a_fixed_colour专门验证了"浅色主题上foreground近乎黑色、深色主题上近乎白色"的跟随行为,a_styled_thumb_still_beats_the_theme_derived_default则确认显式样式仍然压过主题派生值。
自定义视口几何
视口(viewport)默认来自ScrollbarHandle::viewport_bounds。两个覆盖手段支持复合控件或自绘控件:
Scrollbar::vertical(&scroll_handle) .viewport_bounds(editor_content_bounds); Scrollbar::vertical(&scroll_handle) .viewport_from_layout();- 使用
viewport_bounds:当你的自绘视口与句柄的布局边界不一致时,例如文本编辑器仅需高亮实际可见区域(源码注释明确提到 custom-painted viewports, such as the text editor); - 使用
viewport_from_layout:当定位的覆盖容器本身就精确代表视口时,例如固定表头下方的表格主体。此时滚动条直接采用自身元素的布局边界。
视口解析优先级是viewport_bounds覆盖 >viewport_from_layout> 句柄上报,见resolved_viewport_bounds(crates/base/src/scrollbar.rs)。测试explicit_viewport_bounds_override_handle_bounds与layout_viewport_uses_current_element_bounds覆盖了这两种路径。
覆盖内容尺寸:仅当句柄无法报告完整可滚动范围时才需要:
Scrollbar::vertical(&scroll_handle) .scroll_size(gpui_kit::size(px(800.), px(4_000.)));scroll_size默认取scroll_handle.content_size(),见prepaint中的self.scroll_size.unwrap_or(self.scroll_handle.content_size())(crates/base/src/scrollbar.rs)。
无溢出即隐藏:当滚动内容尺寸小于等于容器尺寸时,该轴直接跳过绘制与交互(if scroll_area_size <= container_size { ... continue; },crates/base/src/scrollbar.rs)。测试no_overflow_has_no_interactive_track验证了无溢出时点击轨道不会产生任何滚动偏移。
双轴避让:双轴模式下,水平滚动条会自动为垂直滚动条让出margin_end = track_width,避免两者重叠;若垂直条因无溢出被隐藏,水平条则占满全宽(has_both标记会在跳过时置回false,crates/base/src/scrollbar.rs)。
自定义滚动句柄
ScrollHandle、UniformListScrollHandle与ListState均已实现ScrollbarHandletrait(见 crates/base/src/scrollbar.rs)。自定义滚动容器可以实现同一个 trait 接入滚动条:
use gpui_kit::{Bounds, Pixels, Point, Size}; use gpui_kit::base::ScrollbarHandle; impl ScrollbarHandle for MyScrollState { fn viewport_bounds(&self) -> Bounds<Pixels> { self.viewport_bounds() } fn offset(&self) -> Point<Pixels> { self.offset() } fn set_offset(&self, offset: Point<Pixels>) { self.set_offset(offset); } fn content_size(&self) -> Size<Pixels> { self.content_size() } fn start_drag(&self) { self.set_scrollbar_dragging(true); } fn end_drag(&self) { self.set_scrollbar_dragging(false); } }ScrollbarHandle的完整契约(crates/base/src/scrollbar.rs):
| 方法 | 必选 | 说明 |
|---|---|---|
viewport_bounds() -> Bounds<Pixels> | 是 | 滚动条覆盖的视口边界 |
offset() -> Point<Pixels> | 是 | 当前滚动偏移 |
set_offset(offset) | 是 | 设置滚动偏移 |
content_size() -> Size<Pixels> | 是 | 内容完整尺寸(含 padding) |
start_drag() | 否 | 开始拖拽滑块时回调 |
end_drag() | 否 | 结束拖拽滑块时回调 |
start_drag与end_drag是可选的。当滚动容器需要在滑块拖拽期间挂起吸附(snapping)、选区或其他行为时使用它们。只有实际被拖拽的轴会在鼠标抬起时收到end_drag——松开鼠标时,paint中的MouseUpEvent处理器会检查state.get().dragged_axis == Some(axis)再调用end_drag(crates/base/src/scrollbar.rs)。ListState的实现把这两个回调接到scrollbar_drag_started/scrollbar_drag_ended,测试thumb_drag_notifies_handle_start_and_end验证了拖拽全程的 start/end 配对。
稳定身份(Stable Identity)
Scrollbar::new、vertical、horizontal会从其调用位置(Location::caller())派生元素 ID(见 crates/base/src/scrollbar.rs)。当同一个调用点产生多个相互独立的滚动条时,需要显式设置稳定 ID:
Scrollbar::vertical(&scroll_handle).id(("activity-list", panel_id));稳定身份会在多次渲染之间保留可见性与宽度动画的续存状态(retained state),避免每次重绘都重置动画进度。
交互实现细节:轨道点击、滑块拖拽与帧率限制
滚动条的交互逻辑全部在paint阶段注册(crates/base/src/scrollbar.rs):
- 轨道点击跳转:点击轨道空白处时,滚动条以点击位置为滑块中心换算滚动百分比,并 clamp 到合法范围后调用
set_offset,实现"点击轨道跳转"; - 滑块拖拽:鼠标按下命中滑块时调用
start_drag并记录按下点与滑块的相对偏移;拖拽移动时按比例换算偏移并set_offset,同时stop_propagation避免触发文本选择等副作用; - 帧率限制:拖拽更新默认限制为120 FPS(
max_fps: usize,可用.max_fps(...)调整并被 clamp 在 30..120)。在每次更新前检查距上次更新的间隔是否超过1000 / max_fps毫秒,用于降低复杂交互场景下的 CPU 占用(见 crates/base/src/scrollbar.rs)。该 API 标注为#[doc(hidden)],属于高级调优项; - 完整的轨道命中区:即使绘制的滑块很窄,完整轨道命中盒始终可交互(
bar_hitbox覆盖整个轨道区域),保证窄滑块也容易点中。
可访问性与交互检查清单
在Scrolling/Hover/Always三种模式下接入滚动条后,请对照以下清单验收(原文见 website/base/primitives/scrollbar.md):
- 保持底层视口的滚轮、触控板与键盘滚动始终可用;
- 即使绘制的滑块很窄,也要保留完整轨道的默认交互命中区;
- 滑块在 normal、hover、active 三种状态下都要有足够的对比度;
- 不要通过移动布局或命中盒来实现入场动画(滚动条的布局与命中盒始终固定);
- 分别在开启与关闭 reduced motion 的情况下测试
Scrolling、Hover、Always三种模式; - 独立测试垂直、水平与双轴溢出场景。
此外,从源码契约看还有几条值得注意的默认行为:内容不溢出时该轴滚动条自动隐藏且不可交互;Scrolling模式下悬停无法唤出完全隐藏的滚动条,但已可见的滚动条在悬停期间会续存可见性;双轴模式自动为垂直条让位。理解这些默认行为有助于在复杂布局(如表格固定表头 + 主体滚动)中正确选用viewport_bounds/viewport_from_layout覆盖项。
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考