gpui-kit List 组件完全指南:基于 GPUI 的虚拟化、可搜索、可分区列表开发实践

gpui-kit List 组件完全指南:基于 GPUI 的虚拟化、可搜索、可分区列表开发实践 gpui-kit List 组件完全指南基于 GPUI 的虚拟化、可搜索、可分区列表开发实践【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kitList 是 gpui-kit 中一个功能完整的列表组件它构建在 GPUI 的虚拟列表v_virtual_list之上通过 Delegate 委托模式提供数据管理与自定义渲染能力支持分区Section、搜索、多状态选中、无限滚动、拖拽排序、加载/空状态等桌面应用常见场景。读完本文你将掌握ListState/ListDelegate的核心协议、各配置项的默认值与源码实现细节并能在自己的 GPUI 应用中直接复刻出文件浏览器、联系人列表等真实案例。组件概览一个组件覆盖桌面列表的全部高频需求从官方文档定义看List 是一个 virtualized, searchable list interface核心能力包括虚拟化渲染只渲染可视区域内的行底层基于v_virtual_list见 list.rs 中的渲染调用配合VirtualListScrollHandle实现高效滚动分区Section每个分区可有独立的 header 与 footeritems_count为 0 的空分区会被自动隐藏搜索可选的顶部搜索输入框委托实现perform_search即可接入过滤逻辑选中与确认支持单选、confirmed打勾确认状态、右键选中set_right_clicked_index无限滚动滚动接近底部时通过has_more/load_more_threshold/load_more触发异步加载拖拽排序ListItem实现了 GPUI 的InteractiveElement/StatefulInteractiveElement可直接挂载on_drag、on_drop等原生交互空状态 / 加载状态 / 初始状态分别由render_empty、render_loading、render_initial三个委托钩子提供。从源码结构看模块被拆分为 7 个文件各司其职mod.rs 负责导出list.rs 实现ListState、List元素与事件delegate.rs 定义ListDelegate协议list_item.rs 实现ListItemseparator_item.rs 实现分隔条loading.rs 提供内置骨架屏cache.rs 维护扁平化的行缓存RowsCache。导入与快速开始导入路径use gpui_kit::component::list::{List, ListState, ListDelegate, ListItem, ListEvent, ListSeparatorItem}; use gpui_kit::component::IndexPath;list模块在 lib.rs 中以pub mod list;导出IndexPath则与其它基础类型一起从gpui_kit::component顶层导出。最小可用示例创建列表的核心是实现ListDelegate→ 创建ListState→ 用List::new(state)渲染。struct MyListDelegate { items: VecString, selected_index: OptionIndexPath, } impl ListDelegate for MyListDelegate { type Item ListItem; fn items_count(self, _section: usize, _cx: App) - usize { self.items.len() } fn render_item( mut self, ix: IndexPath, _window: mut Window, _cx: mut ContextListStateSelf, ) - OptionSelf::Item { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child(Label::new(item.clone())) .selected(Some(ix) self.selected_index) }) } fn set_selected_index( mut self, ix: OptionIndexPath, _window: mut Window, cx: mut ContextListStateSelf, ) { self.selected_index ix; cx.notify(); } } // 创建 delegate let delegate MyListDelegate { items: vec![Item 1.into(), Item 2.into(), Item 3.into()], selected_index: None, }; // 创建 ListState 实体 let state cx.new(|cx| ListState::new(delegate, window, cx));渲染只需要一行div().child(List::new(state))IndexPath分区的坐标体系IndexPath是列表定位的关键类型定义在 index_path.rs包含section、row、column三个字段默认值均为 0IndexPath::new(5) // section0, row5 IndexPath::new(0).section(1) // 第 1 分区的第 0 行构造器IndexPath::new(row)默认section 0section()/row()/column()均为链式 settereq_row()用于只比较section与row是否相等index_path.rs。ListDelegate组件与数据的契约ListDelegate是 List 的数据协议定义在 delegate.rs。其中必须实现的有 3 个方法方法作用说明items_count(self, section, cx) - usize返回某分区的条目数无默认实现必须提供render_item(mut self, ix, window, cx) - OptionSelf::Item渲染指定索引的条目返回None则跳过该条目type Item: Selectable IntoElementset_selected_index(mut self, ix, window, cx)保存选中索引只保存不触发确认其余方法全部有默认实现按需覆写方法默认值说明sections_count1分区数最小为 1perform_search(query, ...)Task::ready(())搜索输入变化时调用render_section_header/render_section_footerNone分区头/尾所有 header 高度需一致footer 同理render_empty居中显示IconName::Inbox图标列表为空时展示render_initialNone搜索框为空时的初始视图如上次搜索结果loadingfalse是否处于加载中render_loading内置Loading3 条骨架屏自定义加载视图has_morefalse是否还有更多数据load_more_threshold20距底部剩余多少个实体header/footer/行都算时触发加载load_more空实现在后台任务中追加数据confirm(secondary, ...)空实现用户点击条目或按下 Enter 时回调cancel空实现按下 ESC 时回调set_right_clicked_index空实现右键选中某行时回调关键约束所有条目等高list.rs 的文档注释明确写着 List required all items has the same height列表要求所有条目高度一致。这是因为ListState只测量一个代表性条目item_to_measure_index指定的条目若不存在则回退到第一个非空分区的第一个条目将其高度应用到所有条目上见 list.rs 的prepare_items_if_needed与 cache.rs 的RowsCache::prepare_if_needed。分区 header/footer 同样各测量一次并复用高度。分区列表Sections实现sections_count、render_section_header、render_section_footer即可获得带分区的列表impl ListDelegate for MyListDelegate { type Item ListItem; fn sections_count(self, _cx: App) - usize { 3 // Number of sections } fn items_count(self, section: usize, _cx: App) - usize { match section { 0 5, 1 3, 2 7, _ 0, } } fn render_section_header( mut self, section: usize, _window: mut Window, cx: mut ContextListStateSelf, ) - Optionimpl IntoElement { let title match section { 0 Section 1, 1 Section 2, 2 Section 3, _ return None, }; Some( h_flex() .px_2() .py_1() .gap_2() .text_sm() .text_color(cx.theme().muted_foreground) .child(Icon::new(IconName::Folder)) .child(title) ) } fn render_section_footer( mut self, section: usize, _window: mut Window, cx: mut ContextListStateSelf, ) - Optionimpl IntoElement { Some( div() .px_2() .py_1() .text_xs() .text_color(cx.theme().muted_foreground) .child(format!(End of section {}, section 1)) ) } }注意items_count为 0 的分区会被自动隐藏其 header 与 footer 也不会渲染。这一行为在 delegate.rs 与 cache.rs 中均有实现——构建扁平行缓存时空分区直接跳过不产生任何SectionHeader/SectionFooter条目。行缓存如何扁平化从源码结构看RowsCache将分区 → 行的二维结构展开成一维实体列表cache.rs每个非空分区依次生成SectionHeader(section)、若干Entry(IndexPath)、SectionFooter(section)三个RowEntry变体同时记录每个实体的像素尺寸。虚拟列表据此计算滚动总长与可视窗口position_of负责把IndexPath映射到扁平索引cache.rs。带图标与操作按钮的列表项ListItem内置了suffix后缀区域、on_click、on_mouse_down、on_mouse_enter等能力适合做文件/消息这类带操作的条目fn render_item( mut self, ix: IndexPath, _window: mut Window, cx: mut ContextListStateSelf, ) - OptionSelf::Item { self.items.get(ix.row).map(|item| { ListItem::new(ix) .child( h_flex() .items_center() .gap_2() .child(Icon::new(IconName::File)) .child(Label::new(item.title.clone())) ) .suffix(|_, _| { Button::new(action) .ghost() .small() .icon(IconName::MoreHorizontal) }) .selected(Some(ix) self.selected_index) .on_click(cx.listener(move |this, _, window, cx| { this.delegate_mut().select_item(ix, window, cx); })) }) }suffix接收一个闭包在渲染时构造后缀元素例如更多操作按钮见 list_item.rs。注意ListItem::new(id)的id直接接受IndexPath实现了IntoElementId格式为index-path(section,row,column)见 index_path.rs保证每个条目拥有稳定的唯一标识。搜索内置输入框 委托过滤List 默认不显示搜索框需要调用.searchable(true)开启开启后列表顶部会出现一个带搜索图标的输入框占位文本由 i18n 提供英文 Search...、中文 搜索...见 ui.yml可用List::new(state).search_placeholder(...)覆盖list.rs。impl ListDelegate for MyListDelegate { fn perform_search( mut self, query: str, _window: mut Window, _cx: mut ContextListStateSelf, ) - Task() { // 基于 query 过滤数据 self.filtered_items self.all_items .iter() .filter(|item| item.to_lowercase().contains(query.to_lowercase())) .cloned() .collect(); Task::ready(()) } } let state cx.new(|cx| ListState::new(delegate, window, cx).searchable(true)); List::new(state)源码层的搜索细节从 list.rs 的实现看搜索有几点值得注意触发链路输入框的InputEvent::Change→start_search→ 调用delegate.perform_search外部也可通过state.set_query(str, window, cx)编程式触发搜索该方法内部直接调用start_search因为set_value不会发出Change事件防抖与防闪烁搜索完成后强制等待 100ms 再关闭加载态避免快速输入时 UI 闪烁list.rs搜索期间状态搜索时会调用set_searching(true)让输入框进入 loading 态查询为空且非搜索态时若实现了render_initial则展示初始视图list.rs焦点策略当searchable(true)时整个列表的Focusable焦点会落到搜索输入框上而非列表本体list.rs用户打开列表即可直接键入搜索。加载状态与无限滚动加载状态委托返回loading() true时列表整体显示加载视图而非列表项impl ListDelegate for MyListDelegate { fn loading(self, _cx: App) - bool { self.is_loading } fn render_loading( mut self, _window: mut Window, _cx: mut ContextListStateSelf, ) - impl IntoElement { // 自定义加载视图 v_flex() .justify_center() .items_center() .py_4() .child(Skeleton::new().h_4().w_full()) .child(Skeleton::new().h_4().w_3_4()) } }不覆写render_loading时默认使用内置Loading骨架屏——3 条由Skeleton组成的不可交互条目loading.rs每条内含主/次两行占位。无限滚动无限滚动由三个委托方法协同工作impl ListDelegate for MyListDelegate { fn has_more(self, _cx: App) - bool { self.has_more_data } fn load_more_threshold(self) - usize { 20 // 距底部还剩 20 个实体时触发 } fn load_more(mut self, window: mut Window, cx: mut ContextListStateSelf) { if self.is_loading { return; } self.is_loading true; cx.spawn_in(window, async move |view, window| { // 模拟 API 请求 Timer::after(Duration::from_secs(1)).await; view.update_in(window, |view, _, cx| { // 追加更多数据 view.delegate_mut().load_more_items(); view.delegate_mut().is_loading false; cx.notify(); }); }).detach(); } }触发逻辑在 list.rs 的load_more_if_need中当可视范围的结束位置满足visible_end entities_count.saturating_sub(threshold)时若has_more()为真则把load_more派发到一个后台任务执行。文档与源码都强调两点load_more_threshold默认 20应以实体为单位分区 header、footer、行均计入且应小于首次加载的总行数load_more可能被频繁调用必须自行加锁如示例中的is_loading标志避免重复请求。列表事件与键盘交互ListState实现了EventEmitterListEventlist.rs通过cx.subscribe订阅// 订阅列表事件 let _subscription cx.subscribe(state, |_, _, event: ListEvent, _| { match event { ListEvent::Select(ix) { println!(Item selected at: {:?}, ix); } ListEvent::Confirm(ix) { println!(Item confirmed at: {:?}, ix); } ListEvent::Cancel { println!(Selection cancelled); } } });三个事件的含义见 list.rs事件触发时机ListEvent::Select(IndexPath)选中移动到某条目键盘上下键或点击ListEvent::Confirm(IndexPath)点击条目或按下 EnterListEvent::Cancel按下 ESC 取消选中键盘绑定在init中注册list.rs绑定到Listkey context按键动作行为up/downSelectUp/SelectDown在条目间移动选中越过首/尾行时循环到对端且跳过空分区cache.rs 的prev/nextenterConfirm { secondary: false }确认当前选中条目secondary-enterConfirm { secondary: true }次级确认可用于打开菜单等escapeCancel取消选中默认reset_on_cancel true会清空选中索引随后发出Cancel事件list.rs此外ListState还支持右键点击条目会设置右键索引并回调set_right_clicked_index当存在右键索引时点击列表外部会清除它list.rs 与 list.rs。多样化的列表项样式ListItem提供了丰富的状态组合文档示例覆盖了最常见的几种// 基础条目自带 hover 效果 ListItem::new(ix) .child(Label::new(Basic Item)) .selected(is_selected) // 带勾选图标的条目 ListItem::new(ix) .child(Label::new(Checkable Item)) .check_icon(IconName::Check) .confirmed(is_confirmed) // 禁用条目 ListItem::new(ix) .child(Label::new(Disabled Item)) .disabled(true) // 分隔条条目 ListSeparatorItem::new() .child( div() .h_px() .w_full() .bg(cx.theme().border) )从 list_item.rs 的渲染实现可以归纳出选中视觉selected与secondary_selected右键共用高亮默认使用list_active背景色当主题设置active_highlight为真时ListSettings::active_highlight默认true见 list_settings.rs还会叠加一层list_active_border边框hover 管理条目在非激活状态下使用主题的list_hover背景色源码注释特别提醒用on_hover而不是.hover()来管理 hover 样式list_item.rs分隔条ListSeparatorItem内部渲染为一个disabled(true)的ListItemseparator_item.rs因此天然不可选中、不参与键盘导航禁用态disabled条目文字变灰muted_foreground且不注册任何交互监听list_item.rs。拖拽排序由于ListItem实现了 GPUI 的InteractiveElement与StatefulInteractiveElementlist_item.rson_drag、on_drop、drag_over、on_hover等原生交互 API 全部开箱可用#[derive(Clone)] struct DragItem { ix: IndexPath, name: SharedString, } impl Render for DragItem { fn render(mut self, _: mut Window, cx: mut ContextSelf) - impl IntoElement { // 拖拽时跟随鼠标的预览元素 div() .px_2() .py_1() .bg(cx.theme().accent) .text_color(cx.theme().accent_foreground) .rounded(cx.theme().radius) .child(self.name.clone()) } } // 在 ListDelegate::render_item 中 ListItem::new(ix) .child(Label::new(item.name.clone())) .on_drag(DragItem { ix, name: item.name.clone() }, |drag, _, _, cx| { cx.new(|_| drag.clone()) }) .drag_over::DragItem(|style, _, _, cx| style.bg(cx.theme().drop_target)) .on_drop(cx.listener(move |this, drag: DragItem, _, cx| { this.delegate_mut().move_item(drag.ix, ix); cx.notify(); }))真实的拖拽排序实现可参考 list_story.rs 中的股票列表示例它自定义了CompanyListItem包装ListItem通过on_drag_move根据鼠标位置计算落点位于目标行的上方还是下方DropPosition::Before/After在行间绘制 2px 的插入指示线on_drop时把拖拽项移动到对应插入间隙见 list_story.rs 与 list_story.rs 的move_company。空状态与初始状态自定义空状态列表没有条目时即items_count总和为 0会调用render_empty。默认实现是居中显示一个IconName::Inbox图标delegate.rs可按需覆写impl ListDelegate for MyListDelegate { fn render_empty(mut self, _window: mut Window, cx: mut ContextListStateSelf) - impl IntoElement { v_flex() .size_full() .justify_center() .items_center() .gap_2() .child(Icon::new(IconName::Search).size_16().text_color(cx.theme().muted_foreground)) .child( Label::new(No items found) .text_color(cx.theme().muted_foreground) ) .child( Label::new(Try adjusting your search terms) .text_sm() .text_color(cx.theme().muted_foreground.opacity(0.7)) ) } }初始状态render_initialrender_initial是 List 的一个差异化能力当列表可搜索且搜索框为空时可以展示初始视图而非列表本身——例如上次的搜索结果、或一个欢迎引导页delegate.rs。它适合先展示引导、再展示结果的交互设计。配置项与滚动控制List 元素配置List元素本身支持链式配置与Styled/SizabletraitList::new(state) .max_h(px(400.)) // 设置最大高度 .scrollbar_visible(false) // 隐藏滚动条默认 true .paddings(Edges::all(px(8.))) // 设置内边距 .search_placeholder(Search...) // 自定义搜索框占位文本 .with_size(Size::Small) // 紧凑尺寸影响搜索输入框密度各选项在 list.rs 中实现max_h与paddings通过Styled设置在RenderOnce::render时被提取到ListOptions并应用到内部的虚拟列表启用ListSizingBehavior::Infer因此它们不会污染外层容器样式list.rsscrollbar_visible控制是否在右侧渲染Scrollbar::vertical默认truewith_size(Size::Small)会让搜索输入框区域使用更紧凑的px_1p5内边距list.rs。ListState 滚动与选中控制// 滚动到指定条目第 1 分区的第 0 行使用居中对齐策略 state.update(cx, |state, cx| { state.scroll_to_item( IndexPath::new(0).section(1), // section 1 的 row 0 ScrollStrategy::Center, window, cx, ); }); // 滚动到当前选中条目 state.update(cx, |state, cx| { state.scroll_to_selected_item(window, cx); }); // 只设置选中索引不滚动 state.update(cx, |state, cx| { state.set_selected_index(Some(IndexPath::new(5)), window, cx); });从源码看滚动是延迟执行的scroll_to_item把(IndexPath, ScrollStrategy)存入deferred_scroll_to_index在下一帧render时通过RowsCache::position_of转成扁平索引再真正滚动list.rs 与 list.rs。特殊地滚动目标是首个条目时直接回到顶部。其它常用 APIstate.set_selected_index(...)不滚动而内部方法_set_selected_index会附带滚动到选中项list.rsstate.selected_index()读取当前选中索引state.set_query(str, window, cx)编程式设置搜索词并立即触发搜索state.set_item_to_measure_index(ix, window, cx)指定用于高度测量的条目缺省时自动回退到第一个非空分区的第一行state.focus(window, cx)聚焦列表可搜索时聚焦输入框。完整示例示例一文件浏览器列表struct FileBrowserDelegate { files: VecFileInfo, selected: OptionIndexPath, } #[derive(Clone)] struct FileInfo { name: String, is_directory: bool, size: Optionu64, } impl ListDelegate for FileBrowserDelegate { type Item ListItem; fn render_item(mut self, ix: IndexPath, window: mut Window, cx: mut ContextListStateSelf) - OptionSelf::Item { self.files.get(ix.row).map(|file| { let icon if file.is_directory { IconName::Folder } else { IconName::File }; ListItem::new(ix) .child( h_flex() .items_center() .justify_between() .w_full() .child( h_flex() .items_center() .gap_2() .child(Icon::new(icon)) .child(Label::new(file.name.clone())) ) .when_some(file.size, |this, size| { this.child( Label::new(format_size(size)) .text_sm() .text_color(cx.theme().muted_foreground) ) }) ) .selected(Some(ix) self.selected) }) } }这个示例展示了两个实用技巧用IconName::Folder/IconName::File区分目录与文件用when_some在文件有大小时才渲染大小标签目录不显示。示例二按字母分组的联系人列表struct ContactListDelegate { contacts_by_letter: BTreeMapchar, VecContact, selected: OptionIndexPath, } impl ListDelegate for ContactListDelegate { type Item ListItem; fn sections_count(self, _cx: App) - usize { self.contacts_by_letter.len() } fn render_section_header(mut self, section: usize, _window: mut Window, cx: mut ContextListStateSelf) - Optionimpl IntoElement { let letter self.contacts_by_letter.keys().nth(section)?; Some( div() .px_3() .py_2() .bg(cx.theme().background) .border_b_1() .border_color(cx.theme().border) .child( Label::new(letter.to_string()) .text_lg() .text_color(cx.theme().accent_foreground) .font_weight(FontWeight::BOLD) ) ) } }用BTreeMapchar, VecContact天然按字母序分桶section索引即字母顺序header 采用吸顶式设计独立背景 底部边框让分组在滚动时清晰可辨。示例三仓库中的真实综合用例仓库自带的展示程序 list_story.rs 是一个接近生产级的综合示例crates/story是可运行的 showcase它在一个列表中同时融合了分区按行业industry动态分组并刻意返回 0 条空分区用于测试隐藏逻辑搜索perform_search基于公司名做不区分大小写的包含匹配懒加载load_more_threshold 150滚动时模拟 1 秒网络延迟后每次追加 200 条直到 6000 条为止list_story.rs拖拽排序跨分区的拖到某行上方/下方重排并绘制插入指示线事件驱动通过ListEvent订阅打印Select/Confirm/Cancel在confirm(secondary)中派发应用级 action。该文件的CompanyListDelegate是学习如何组织一个真实列表数据源的最佳范本。最佳实践与注意事项条目必须等高所有render_item返回的条目高度需一致否则虚拟列表的行高缓存会错位。分区 header/footer 同理各自等高即可。阈值要小于首屏数据量load_more_threshold默认 20若首次加载不足 20 行滚动时会立即触发加载且load_more里必须加锁如is_loading标志防止并发重复请求。空分区零成本items_count 0的分区不生成任何 header/footer 实体键盘导航上/下键会自动跳过空分区有对应的单元测试覆盖见 cache.rs。搜索防闪烁搜索完成后的 100ms 等待是内置行为perform_search内部应把过滤逻辑放在异步任务中避免阻塞 UI。善用初始状态需要进入先引导、搜索后出结果的产品实现render_initial比在外部做条件渲染更符合组件心智模型。右键菜单的配合右键选中通过set_right_clicked_index通知委托配合popover/menu组件即可实现上下文菜单点击列表外部会自动清除右键态。高度测量回退当指定测量条目不存在时如被搜索过滤掉prepare_items_if_needed会自动回退到第一个非空分区的第一行测量逻辑本身有测试保障list.rs。小结gpui-kit 的 List 组件把桌面应用列表最常见的需求——虚拟化、分区、搜索、无限滚动、拖拽、多状态视觉——收敛进一个清晰的两层架构ListState负责状态与渲染ListDelegate负责数据与业务。你只需实现 3 个必需方法与若干可选钩子即可获得一个具备完整键盘导航、无障碍ARIA标注与主题一致性的高性能列表。文中所有结论均可对照仓库源码验证核心实现在 list.rs 与 delegate.rs条目与分隔条在 list_item.rs 与 separator_item.rs行缓存与测量在 cache.rs综合实战示例在 list_story.rs。【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考