24 KiB
ListView 桌面版实现原理
本文档描述 ListView 桌面版的内部源码结构、数据视图归一、选择状态、分页、分组、过滤、容器生命周期、虚拟化上下文和主题接入。公共设计与 API 契约见 ListView 桌面版架构设计,完整选择设计见 ListView 选择模型设计,Token 语义见 ListView Token 设计,变化记录见 ListView Changelog。
1. 实现定位
ListView 的实现目标是在 Avalonia ItemsControl 基础上增加 AtomUI 数据视图列表能力,并保持 public API、主题契约和容器生命周期稳定。实现文档覆盖 ListView、ListViewItem、选择 partial、分页 partial、虚拟化上下文、ListCollectionView 协作、ListView token 和 AXAML theme。
本文档不逐行复述属性注册、CLR wrapper 和小型 guard。维护具体行为时仍应直接阅读源码,尤其是 ListView.Selecting.cs、ListCollectionView.cs 和 ListView.Pagination.cs。
2. 源码文件结构
主要源码:
src/AtomUI.Desktop.Controls/ListView/ListView.cs:public API、事件、ItemsSource 归一、collection view 订阅、分组、过滤、排序、容器生成、空状态、操作态和点击派发。src/AtomUI.Desktop.Controls/ListView/ListView.Selecting.cs:选择模型接入、source entry / view node 映射、SelectedIndex / SelectedIndexes / SelectedItem / SelectedItems / SelectedValue、键盘导航、文本搜索和容器 selected 状态同步。src/AtomUI.Desktop.Controls/ListView/ListView.Pagination.cs:PageIndex / PageSize、分页器接入、分页器属性同步和 page change 请求。src/AtomUI.Desktop.Controls/ListView/ListView.Virtualizing.cs:容器回收时的上下文保存、恢复和本地值清理。src/AtomUI.Desktop.Controls/ListView/ListViewItem.cs:条目容器、selected attached property、点击 routed event、pointer selection、组标题状态、selected indicator 和 motion 生命周期。src/AtomUI.Desktop.Controls/ListView/ListViewSelectionModel.cs:ListView canonical selection model,持有 selected EntryIds、anchor 和 active entry,并发布 source-index 与 item 投影。src/AtomUI.Desktop.Controls/ListView/ListDefaultFilter.cs:把 collection viewFilterDescriptions聚合成Func<object, bool>。src/AtomUI.Desktop.Controls/ListView/ListPaginationVisibility.cs:分页器可见性枚举。src/AtomUI.Desktop.Controls/ListView/ListPseudoClass.cs::empty和:singleitem伪类常量。src/AtomUI.Desktop.Controls/ListView/ListViewToken.cs:ListView 专属 Token 定义。src/AtomUI.Desktop.Controls/ListView/Themes/ListViewTheme.axaml:root 模板、分页 presenter、Spin、ScrollViewer、ItemsPresenter、EmptyIndicator、默认 item / group template 和 root 样式。src/AtomUI.Desktop.Controls/ListView/Themes/ListViewItemTheme.axaml:条目模板、selected indicator、普通内容、组标题状态和条目状态样式。src/AtomUI.Controls.Shared/Data/ListCollectionViews/:IListCollectionView、source entry、view node、entry/index 映射、排序、过滤、分组和数据项契约。
3. 核心类职责
ListView 是列表根控件,负责把 Avalonia ItemsSource 归一为 IListCollectionView,将排序、过滤、分组和分页 public API 写入 collection view,并把 collection view 状态同步到空状态、分页器、伪类和选择模型。
ListViewItem 是条目容器,继承 ContentControl 并实现 ISelectable 和 IListItemVirtualizingContextAware。它负责内容承载、pointer selection、点击事件、组标题标记、selected indicator 可见性和初始化 / loaded 阶段动效启停。
ListViewSelectionModel 是选择状态的唯一 owner。它以 EntryId 保存选中集合、anchor 和 active entry,接收 source-index 或容器交互请求,并向 ListView 投影 SelectedIndex(es)、SelectedItem(s) 和 SelectedValue。
ListCollectionView 位于 AtomUI.Controls.Shared,负责 source entry 生命周期、当前视图枚举、排序、过滤、分组、分页、源集合变化、entry/index 映射和 page change 事件。ListView 不直接实现这些集合算法。
AbstractPagination 实例由用户注入到 TopPagination 或 BottomPagination。ListView 不创建默认分页器,只在分页器存在时同步 total、page size、current page、align、enabled、motion 和 hide-on-single-page。
4. 状态与数据流
ItemsSource 归一流:
ItemsSource changed
↓
newItemsSource implements internal entry view bridge ?
yes: use entry-capable IListCollectionView
no : newItemsSource is IListCollectionView ?
yes: wrap its SourceCollection with AtomUI ListCollectionView
no : wrap newItemsSource with AtomUI ListCollectionView
↓
subscribe PropertyChanged / CollectionChanged / PageChanging / PageChanged
↓
SetValueNoCallback(ItemsSourceProperty, collectionView)
↓
ConfigureFilterDescription
ConfigureSortDescriptions
ConfigureGroupInfo
ReConfigurePagination
过滤、排序、分组流:
Filter / FilterValue / FilterValueSelector
↓
ListFilterDescription
↓
IListCollectionView.FilterDescriptions
↓
ListDefaultFilter
↓
ListCollectionView.Refresh
SortDescriptions
↓
IListCollectionView.SortDescriptions
IsGroupEnabled / GroupPropertySelector
↓
ListGroupDescription
↓
IListCollectionView.GroupDescriptions
↓
ListCollectionViewGroupRoot
↓
GroupListItemData(Content = groupKey.ToString(), IsGroupItem = true)
分组视图流:
Source item
↓
GroupPropertySelector(item)
↓
group key
↓
ListCollectionViewGroupRoot.AddToSubgroups
↓
new group?
yes: create ListCollectionViewGroupInternal
insert GroupListItemData before first leaf item
no : append / insert leaf item into existing group
↓
RootGroup.GetLeafEnumerator
↓
ListView current view enumeration
容器生成流:
Current view item
↓
CreateContainerForItemOverride -> ListViewItem
↓
PrepareContainerForItemOverride
ContentTemplate <- ItemTemplate / GroupItemTemplate
SizeType / Motion / SelectedIndicator / Item backgrounds
IsShowSelectedIndicator / ItemClickMode
Restore IListItemData default context
Restore virtualizing context by VirtualIndex
↓
ContainerForItemPreparedOverride
sync container IsSelected from Selection
选择状态流:
Pointer / keyboard / text search / SelectedIndex / Selection command
↓
view node or source index
↓
EntryId
↓
ListViewSelectionModel
↓
SelectedIndex(es) / SelectedItem(s) / SelectedValue projections
↓
ListView.IsSelected attached property on container
↓
ListViewItem.IsSelectedIndicatorVisible
SelectedIndex 是 source-index 选择输入。SelectedIndexes、SelectedItem、SelectedItems 和 SelectedValue 是 canonical selection 的只读投影。Selection model 不从 item 或 selected value 反向查找 source entry,同一对象重复出现时可以生成多个独立选中结果。
分页流:
PageSize
↓
IListCollectionView.PageSize
↓
Current view enumerates current page
Pagination.CurrentPageChanged
↓
IListCollectionView.MoveToPage(page - 1)
↓
PageChanged
↓
ListView.PageIndex
↓
TopPagination / BottomPagination CurrentPage
空状态流:
CollectionChanged / TotalItemCount changed
ItemsSource changed
IsShowEmptyIndicator changed
↓
ConfigureEmptyIndicator
↓
IsEffectiveEmptyVisible = IsShowEmptyIndicator && TotalItemCount == 0
↓
PART_ScrollViewer hidden / EmptyIndicator shown
5. 生命周期与模板接入
ListView 静态构造完成以下配置:
- 覆盖默认
ItemsPanel为VirtualizingStackPanel。 - 注册
IsSelectedChangedEventclass handler,同步容器 selected 状态回选择模型。 - 注册
ListViewItem.ClickedEventclass handler,收敛到ItemClicked。 - 监听
IsSelectableProperty,关闭可选状态时清空选择。 - 监听
TotalItemCountProperty,触发ItemCountChanged。 - 监听
ItemsSourceProperty,进入 collection view 归一流程。
ListView 实例构造注册 ListViewToken scope,监听 ItemsView source changed、当前 items collection changed 和 Items.CollectionChanged。集合变化会清理虚拟化恢复上下文、刷新空状态,并校验直接 Items 的数据项语义。
HandleItemsSourcePropertyChanged 是数据视图生命周期核心。它只直接接入具备 internal entry bridge 的 collection view;普通自定义 IListCollectionView 从 SourceCollection 重新包装。view 替换时解除旧 view 的 PropertyChanged、CollectionChanged、PageChanging 和 PageChanged 订阅,仅释放 ListView 自建 view。新 view 接入后设置 IsEmptyDataSource、TotalItemCount、过滤 callback、排序描述、过滤描述、分组描述和分页状态。
OnInitialized 设置默认 GroupPropertySelector,刷新空状态和过滤态,并确保 collection view entry projection 与 selection model 已接入。
分组相关生命周期集中在 collection view 配置阶段:
ConfigureGroupInfo根据IsGroupEnabled和GroupPropertySelector写入或清理GroupDescriptions。ReConfigureGroupInfo在GroupPropertySelector变化时清空旧分组描述,再按当前配置重建。IsGroupEnabled变化后,ListView 使用_collectionView.DeferRefresh()合并分组重建,并刷新 view-node 映射。- 分组只改变 view projection。selection model 始终以业务 source entries 为选择范围,组标题节点不进入该范围。
OnApplyTemplate 接入模板后初始化默认 EmptyIndicator,刷新伪类、空状态和分页器可见性。默认空状态是 Empty simple preset。
PrepareContainerForItemOverride 是 root 状态同步到 item 的核心入口。它先确保 selection model 已创建,再根据 item 类型选择 ItemTemplate 或 GroupItemTemplate,使用 Avalonia [!] binding 将同生命周期状态绑定到 ListViewItem,然后恢复默认数据上下文和虚拟化上下文。
ClearContainerForItemOverride 是容器回收清理入口。它临时关闭 motion 和容器 selected 同步,保存 IsEnabled、IsGroupItem 等虚拟化上下文,清理容器本地值,再调用基类释放容器。新增容器级状态时必须同步补齐 save、restore、clear 三条路径。
6. 交互与事件处理
pointer selection:
- 鼠标左键和右键按下时立即通过 owner ListView 更新 selection。
- touch 和 pen 的普通点击延迟到 release,避免按下阶段阻断滚动手势。
- pen 右键按下按鼠标右键处理。
- 组标题项在
UpdateSelectionFromEventSource中被跳过。
click event:
ItemClickMode=Press时左键按下触发ListViewItem.Clicked。ItemClickMode=Release时左键释放触发ListViewItem.Clicked。- ListView 通过 class handler 接收后调用
NotifyItemClicked,再触发 publicItemClicked。
keyboard navigation:
- 方向键通过
INavigableContainer查找下一个容器,并更新 selection 与 focus。 WrapSelection=true时同步 Avalonia focus wrap 行为。- 多选模式下,平台 select-all 手势调用
Selection.SelectAll()。 - Space / Enter 从事件源更新选择。
text search:
IsTextSearchEnabled=true时,OnTextInput累积输入文本。- 搜索值优先使用
TextSearch.TextBinding,其次使用DisplayMemberBinding。 - 命中第一个前缀匹配项后设置
SelectedIndex。 - 搜索词通过 1 秒
DispatcherTimer清空。
pagination interaction:
- 分页器
CurrentPageChanged只请求 collection view 移动页。 - collection view
PageChanged回写PageIndex。 - 分页器替换时必须解除旧分页器事件和 relay binding。
7. 内部算法与关键流程
7.1 Collection view 桥接
ListView 接收 ItemsSource 后,只直接使用实现 internal entry view bridge 的 IListCollectionView,并且不拥有其生命周期。普通自定义 IListCollectionView 使用其 SourceCollection 创建 AtomUI ListCollectionView;其他 IEnumerable 直接包装,并在替换时释放自建 view。随后通过 SetValueNoCallback 把归一后的 view 写回 ItemsSourceProperty,避免重复进入 property changed handler。
此路径要求维护者严格区分:
- 用户设置的原始数据源。
- ListView 当前持有的
_collectionView。 - 控件是否拥有
_collectionView。 - collection view 持有的 source entries 与当前 view nodes。
- selection model 持有的 EntryIds 与公开 source-index 投影。
7.2 FilterDescriptions 聚合
ListView 使用 ConfigureFilterDescription 清空并重建 collection view 的 FilterDescriptions。当 FilterValue 和 Filter 同时存在时,创建 ListFilterDescription,并把 FilterValueSelector 或 IListItemData.Content 默认 selector 作为过滤值入口。
ListDefaultFilter 将多个 FilterDescriptions 做 AND 聚合。它通过 WeakReference<IListCollectionView> 持有 view,避免 filter callback 成为强引用链。
7.3 分组视图
分组开启时,ConfigureGroupInfo 向 collection view 写入 ListGroupDescription。默认 selector 从 IGroupHeader.Group 读取 group key,用户可通过 GroupPropertySelector 覆盖。
ListView public API 当前只创建一个 ListGroupDescription,因此 ListView 契约是单层分组。IListCollectionView.GroupDescriptions 本身是列表,属于 shared collection view 能力;除非 ListView 显式增加 public API,否则不应把多层分组写进 ListView 控件契约。
ListCollectionViewGroupRoot.AddToSubgroups 负责生成分组结构:
- 使用当前层级的
IListGroupDescription.GroupKeyFromItem取得 group key。 - 如果 group key 命中已有 subgroup,则把数据项加入该 subgroup。
- 如果 group key 是新 key,则创建
ListCollectionViewGroupInternal。 - 新 subgroup 创建后,先插入一个
GroupListItemData,其Content为key.ToString(),IsGroupItem=true。 - 再把原始数据项加入同一 subgroup。
RootGroup.GetLeafEnumerator 会把组标题项和真实数据项一起展平成当前 view 枚举结果。ListView 因此不需要单独维护组标题集合;组标题与普通项走同一容器生成路径。
容器准备时,如果 item 实现 IGroupListItemData 且 IsGroupItem=true,ListView 使用 GroupItemTemplate;否则使用 ItemTemplate。组标题通过 ListViewItem.IsGroupItem 进入主题 selector,并在 pointer selection 入口被排除。
分组与分页组合时,collection view 使用两段式准备:
PrepareTemporaryGroups
build grouping for full filtered / sorted internal list
↓
PrepareGroupsForCurrentPage
rebuild exposed _group from current page items
这保证分页后的枚举只包含当前页范围内需要展示的组标题和数据项,同时保留完整结果的分组顺序信息。
维护分组路径时必须注意:
GroupPropertySelector返回null时,当前分组算法不会自动创建默认 group;需要兜底分组时 selector 应返回明确 key。GroupListItemData是 collection view 生成的展示项,不应写回用户 source collection。TotalItemCount在分组态基于当前 viewCount,可能包含组标题展示项;空状态和分页同步必须按当前实现语义处理。- 分组节点没有 EntryId 和 source index;pointer、keyboard、range、select-all 和其他选择入口必须统一跳过组标题。
- 虚拟化上下文必须保存和清理
IsGroupItem,避免普通项被回收容器误显示成组标题,或组标题误使用普通项模板。
7.4 排序视图
SortDescriptions 直接同步到 collection view 的 SortDescriptions。ListSortDescription.FromPath 支持 property path 排序;NativeAOT 场景优先使用生成的数据成员访问器或显式 comparer,避免依赖运行期反射。
7.5 Entry 与索引映射
ListCollectionView 维护 source-index、EntryId 和 view-index 三个域:
source index -> source entry
EntryId -> source index
view index -> item node / group header node
EntryId -> current view index
排序、过滤、分组和分页只重建 view node 顺序以及 EntryId -> view index 映射。公开选择索引始终由 EntryId -> source index 计算,不使用 PageIndex * PageSize 推测源位置,也不把 view item 传给 IndexOf 反查。
过滤或分页隐藏 entry 时,该 entry 没有当前 view index,但仍保留 source index 和选择状态。组标题只存在于 view node 序列,没有 source entry 映射。
7.6 SelectedValue
SelectedValueBinding 使用 BindingEvaluator<object?> 复用 evaluator。主选中 entry 变化或被 Replace 时,ListView 根据对应 item 计算 SelectedValue。SelectedValue 是只读投影,不按 value 遍历 items 反向选择条目。
BeginInit / EndInit 和 DataContext update 期间,ListView 使用选择事务延迟应用 source-index 输入与公开投影通知,避免 Items 和 selection 更新顺序暴露中间状态。
7.7 虚拟化上下文
IListVirtualizingContextAware 以容器 VirtualIndex 保存上下文。当前默认保存:
ListViewItem.IsEnabledListViewItem.IsGroupItem
恢复时通过 ListVirtualizingContextAwareUtils.ExecuteWithinContextClosure 标记上下文操作,避免恢复过程触发错误的本地状态传播。清理时移除 Content、ContentTemplate、IsEnabled 和 IsGroupItem 本地值。
8. 资源、性能与 AOT 边界
ListView 不通过反射访问模板内部结构。模板接入依赖稳定 part 名称、template binding、theme selector 和 Avalonia 属性绑定。
同生命周期的 root 到 item 状态同步使用 Avalonia [!] binding。容器由 ItemsControl 管理,binding 生命周期随容器生命周期结束。
默认 ItemsPanel 是 VirtualizingStackPanel。容器回收路径必须保持本地值对称清理,避免 selected、disabled、group item 和 content 状态泄漏到新数据项。
ListCollectionView 在排序、过滤、分页或分组启用时维护本地数组。维护过滤路径时应避免无活跃过滤条件时引入不必要的 collection view 本地数组刷新。
选择映射使用 source entries、EntryId -> source index 和 EntryId -> view index 索引表。容器准备、选中查询和选择到容器的映射不得执行基于 item equality 的线性 IndexOf。完整 Refresh 允许 O(n) 重建索引表;增量变化更新受影响区间并在同一 refresh transaction 中发布。
ListSortDescription.FromPath 存在运行期属性路径访问能力;NativeAOT 应优先使用 GenerateDataMemberAccessors 生成访问器,或使用 ListSortDescription.FromComparer 提供显式 comparer。
分页器 relay binding 和事件订阅必须在分页器替换时释放。collection view 事件订阅必须在 view 替换时释放,自建 view 必须释放其源集合弱转发器。
文本搜索 timer 不应成为长期后台工作;停止搜索时必须解除 tick 订阅并停止 timer。
9. 维护不变量
内部重构必须保持以下不变量:
ListView.cs保留 public API、事件、ItemsSource 归一、容器生命周期和 collection view 配置入口。ListView.Selecting.cs保持选择模型接入、entry/index 映射、SelectedValue 和键盘 / 文本搜索职责,不把选择状态写入数据项或容器作为 owner。ListViewSelectionModel是 selected EntryIds、anchor 和 active entry 的唯一 owner;公开属性不得维护平行选择状态。- 标识映射路径不得使用 item
Equals、对象引用、IList.IndexOf或IListCollectionView.IndexOf解析 source entry。 SelectedItem、SelectedItems和SelectedValue只能从 canonical selection 投影,不作为反向选择输入。ListView.Pagination.cs只处理分页器接入和 collection view page 状态同步,不执行数据请求。ListViewItem.cs保持条目容器角色,不承载排序、过滤、分页或跨列表全局状态。- collection view 替换时必须解绑旧 view 事件;只有具备 entry bridge 的 view 可以直接接入,且只有 ListView 自建 view 才能由 ListView dispose。
ConfigureFilterDescription、ConfigureSortDescriptions和ConfigureGroupInfo重建描述前必须清理旧描述。IsGroupEnabled切换后只刷新 view projection,并保持组标题项没有 EntryId 且不进入业务选择结果。GroupPropertySelector必须返回稳定 group key;需要兜底分组时由 selector 返回明确 key,不依赖 collection view 自动处理null。GroupListItemData是展示层合成项,不得写回用户源集合或被当作业务数据模型扩展点。- 分组与分页组合时必须保持
PrepareTemporaryGroups和PrepareGroupsForCurrentPage的顺序,不得只对当前页局部数据直接推断全局分组顺序。 PrepareContainerForItemOverride新增状态同步时,必须在ClearContainerForItemOverride或 virtualizing clear 路径中对称清理。- EntryId 映射必须同时覆盖 selection change、container prepared、container index changed、auto scroll 和 selected indicator 同步。
- Add、Remove、Move 和 Replace 必须按 collection change index 更新 entries,不按 item equality 定位变化目标。
- Reset 和 ItemsSource 替换只通过唯一非空 item key 恢复选择,不按 item equality 或旧索引回退。
- 分页器替换时必须解除旧
CurrentPageChanged和 relay binding。 - Token 变更必须同步
ListViewTokenKind、AXAML 引用和 token.md 语义说明。
10. 测试与验证
ListView 相关验证入口:
tools/performances/AtomUI.Performance/Suites/ListView/ListViewStateVerification.cstools/performances/AtomUI.Performance/Suites/ListView/ListViewScenarios.cscontrolgallery/AtomUIGallery/ShowCases/DataDisplay/List/tests/AtomUI.Controls.Shared.Tests/中与 collection view、排序、过滤或数据成员访问器相关的测试。tests/AtomUI.Desktop.Controls.Tests/中与 ListView、Selection 或 Pagination 相关的测试。
建议验证命令:
dotnet run --project tools/performances/AtomUI.Performance/AtomUI.Performance.csproj -c Debug --framework net10.0 --no-restore -- --verify-listview-states
dotnet run --project tools/performances/AtomUI.Performance/AtomUI.Performance.csproj -c Debug --framework net10.0 --no-build -- --suite listview --count 60
dotnet test tests/AtomUI.Controls.Shared.Tests/AtomUI.Controls.Shared.Tests.csproj --framework net10.0 --no-restore
dotnet test tests/AtomUI.Desktop.Controls.Tests/AtomUI.Desktop.Controls.Tests.csproj --framework net10.0 --no-restore
git diff --check
验证重点:
- ItemsSource 包装、替换、entry-capable view 直连、普通
IListCollectionView重新归一和各自生命周期。 - 默认列表、空列表、操作态、分组、过滤、排序和分页显示。
- selected indicator 生命周期:选中、取消选中、切换选中项和虚拟化回收。
- selection 生命周期:重复 item、单选、多选、AlwaysSelected、
IsSelectable=false、SelectedValue、Reset key 恢复、分页和分组。 - pagination 生命周期:分页器替换、可见性、对齐、motion、page size、current page 和 detach 后不再被 mutation。
- 虚拟化回收后 disabled、group item、content template 和 selected 状态不串扰。
- Gallery List ShowCase 的基础和进阶示例显示稳定。