gpui-kit 滚动条 Scrollbar 深度指南:为 GPUI 滚动视图定制带动效的自绘滚动条
2026/9/15 1:09:13 网站建设 项目流程

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()enterexitexpand时长全部为零,只有 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, };

核心使用模式有三步:

  1. ScrollHandle保存在持久的视图状态上(例如结构体字段),保证跨渲染保持稳定;
  2. track_scroll(&self.scroll_handle)把句柄挂到可滚动内容上;
  3. 在同一相对容器内叠加一个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::verticalScrollbar::horizontal只是Scrollbar::new加上.axis(...)的语法糖(见 crates/base/src/scrollbar.rs)。

滚动条是绝对定位覆盖层。其布局与命中盒(hitbox)在布局阶段固定不动,只有绘制的轨道和滑块在动画——因此入场动效不会移动内容,也不会改变交互几何。这一点在request_layout中实现:滚动条元素使用position: Absoluteflex_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_visibletracks_thumb_hoverhover_keeps_visible三个纯函数中(见 crates/base/src/scrollbar.rs),并有对应的单元测试覆盖(如hidden_scrolling_mode_does_not_track_thumb_hovervisible_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_clickhidden_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,它把modemotionstyles三个维度打包为一个可整体换入的全局默认值,并通过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 保持,但enterexitexpand都是零时长。未安装动效的应用因此获得即时的可见性与宽度变化。单元测试base_ships_no_motion_of_its_own专门锁定这一契约,并注释说明 idle 是"行为而非动效",必须保留以保证Scrolling模式可用。

动效行为

上文示例主题会产出如下编排:

触发条件入场方式
ScrollingHover模式下滚动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_progressidle_boundary_starts_the_exit_without_a_jump覆盖了这两种场景。

零时长即到目标:零时长直接采用目标值,即使有过渡正在进行也一样(ScalarTransition::settleset_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_transitionreduced_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):

样式结构支持字段说明
ScrollbarTrackStylebgborder_colorwidth轨道背景、边框色与轨道宽度
ScrollbarThumbStylebgwidthinsetradiusmin_length滑块背景、宽度、内缩、圆角与最小长度

样式级联顺序(见resolve_track/resolve_thumb,crates/base/src/scrollbar.rs)从高到低为:

  1. 当前状态样式(active / hovered 等状态专用);
  2. 实例.styles(...)中对应状态的值;
  3. 全局ScrollbarTheme样式;
  4. 由主题派生或内置的默认值(MIN_THUMB_SIZE = px(48.)兜底min_length)。

主题派生的滑块底色:未显式覆盖时,滑块默认色取自当前主题的tokens.colors.foreground并按状态施加透明度(normal 0.35、hover/active 0.55),而不是写死的黑色——这样浅色/深色主题切换时滑块始终可见(见thumb_default_backgroundstyle_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_boundslayout_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)。

自定义滚动句柄

ScrollHandleUniformListScrollHandleListState均已实现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_dragend_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::newverticalhorizontal会从其调用位置(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 FPSmax_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 的情况下测试ScrollingHoverAlways三种模式;
  • 独立测试垂直、水平与双轴溢出场景。

此外,从源码契约看还有几条值得注意的默认行为:内容不溢出时该轴滚动条自动隐藏且不可交互;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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询