Files

19 KiB
Raw Permalink Blame History

Cascader 桌面版实现原理

本文档描述 Cascader 桌面版的输入壳体、选项集合、级联展开、选择同步、勾选、过滤、异步加载、绑定型选项和资源生命周期。共享输入分层见 输入控件共享架构设计,公共设计与 API 契约见 Cascader 桌面版架构设计,候选列表状态契约见 候选列表统一交互设计,Token 语义见 Cascader Token 设计,变化记录见 Cascader Changelog。

Popup 接入边界:AbstractSelect 负责业务状态和内容准备,PART_Popup 负责实际显示。模板重建或宿主切换时必须先释放旧 relay,再绑定新的 Popup;普通外点、Escape、失焦和业务关闭在 pinned 状态下被拦截,detach、窗口销毁、跨 TopLevel 和无效锚点必须走生命周期关闭并释放 Popup host。完整状态机见 Popup 钉住打开设计。

1. 实现定位

Cascader 的实现由外层 Cascader 和内部 CascaderView 组成。Cascader 负责输入表面、popup、选择结果、默认值、Form 和 AddOn 集成;CascaderView 负责弹层内的级联列、过滤、展开、勾选和异步加载。本文档聚焦 Cascader 自身的状态编排、容器生命周期、数据节点边界和维护不变量,不重复 Select 家族输入壳体的通用实现。

2. 源码文件结构

主要源码:

  • src/AtomUI.Desktop.Controls/Cascader/Cascader.cs:public API、输入壳体、popup、选择结果、默认路径、Form / CompactSpace 集成和外层交互。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderView.cs:弹层内容、选项源、选择、过滤入口、空状态、默认展开路径和模板接入。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderView.ExpandAndCollapse.cs:展开、收起、同级互斥展开、子列创建和默认展开路径。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderView.Check.cs:多选勾选、子树勾选、父级半选和 SelectedOptions 同步。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderView.AsyncItemDataLoad.cs:异步加载、超时、取消、并发加载合并和 loaded 事件。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderView.Filter.cs:过滤路径收集、过滤结果列表、过滤计数和高亮文本。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderViewLevelList.cs:单级列表、容器准备、虚拟化上下文保存和清理。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderViewItem.cs:选项容器、checked / expanded / selected 事件、leaf 判断和绑定型选项同步。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderOption.cs:ICascaderOption、默认轻量 CascaderOption 和层级 helper。
  • src/AtomUI.Desktop.Controls/Cascader/BindableCascaderOption.cs:绑定型选项模型,使用 scoped resource host generator。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderAddOnDecoratedBox.cs:Cascader 输入布局扩展,复用 InputControlFrame 的输入表面状态。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderViewPanel.cs:弹层 frame 布局与边框测量。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderViewFilterList.cs、CascaderViewFilterListItemData.cs:过滤结果列表和路径数据。
  • src/AtomUI.Desktop.Controls/Tooltip/OverflowTip.cs:共享溢出 tooltip attached behavior,供单选路径和多选 tag 复用。
  • src/AtomUI.Desktop.Controls/Cascader/Themes/*.axaml:Cascader、CascaderView、CascaderViewItem、level list 和 filter list 主题。
  • src/AtomUI.Desktop.Controls/Cascader/CascaderToken.cs:Cascader 专属 Token。

3. 核心类职责

Cascader 是用户直接使用的输入控件。它维护 Options、SelectedOption、SelectedOptions、EffectiveSelectedOptions、SelectedOptionPath、popup 状态、清除入口、默认选择路径和 Form 映射。它继承 AbstractSelect 的输入表面、popup、Form feedback、AddOn、CompactSpace、清除和最大选择数量契约。

CascaderView 是 popup 内容控件。它把 OptionsSource 同步到内部 Options,管理 root level list、过滤结果、选项展开、选项选择、多选勾选和异步加载。

CascaderViewLevelList 是单列选项列表。它负责创建 CascaderViewItem,恢复默认上下文,保存虚拟化上下文,并在容器清理时释放绑定型选项的生命周期资源。

CascaderViewItem 是选项容器。它把 option 状态映射为 header、icon、checked、enabled、expanded、checkbox enabled、leaf 和 value。绑定型选项进入容器时,容器同时负责 resource host attach、属性订阅、children 集合订阅和 checked / expanded 反写。

CascaderOption 是默认轻量数据模型。它维护 children 与 parent 的基本关系,不承载 Avalonia 属性系统和动态资源。

BindableCascaderOption 是绑定型数据模型。它继承 AvaloniaObject,通过 DirectProperty 承载选项属性,并使用 [GenerateScopedResourceHost] 生成 scoped resource host 能力。

OverflowTip 是显示增强边界,不拥有 Cascader 选择状态。CascaderTheme.axaml 将单选 SelectedOptionPath、过滤输入 placeholder 路径、多选 SelectTagAwareTextBox 的 tag 文本以及 OverflowTipDelay / OverflowTipPlacement 接入该 behavior;单选路径和过滤输入以外层 CascaderAddOnDecoratedBox 作为 PlacementTarget,避免 tooltip 左边按内部文本 padding 对齐;它只托管自己写入的 ToolTip.Tip,避免与业务自定义 tooltip 形成双 owner。

4. 状态与数据流

4.1 选项源流

Cascader.OptionsSource / Options
      ↓
Cascader.HandleCascaderSourceChanged
      ↓
_options ItemCollection
      ↓
CascaderView.OptionsSource
      ↓
CascaderView.Options
      ↓
PART_RootLevelList.ItemsSource

OptionsSource 变化时,外层 Cascader 会先捕获当前选择的路径 identity,再替换内部 _options。单选模式下按旧路径尝试重映射 SelectedOption,失败时应用 DefaultSelectOptionPath。多选模式下按每个已选项路径重映射 SelectedOptions。

SelectedOption 和 SelectedOptions 是用户受控值,属性注册默认 TwoWay binding,并启用 Avalonia data validation。外层 Cascader 是 Form value owner;内部 CascaderView 只承载 popup 里的展开、选中和勾选视觉状态,不反向拥有外层值。

SelectedOptions 为集合值时,外层会在控件进入视觉树后订阅 INotifyCollectionChanged,离开视觉树或集合替换时释放订阅。集合原地 Add、Remove、Replace、Move、Reset 会刷新 EffectiveSelectedOptions 展示快照、SelectedCount、IsSelectionEmpty、Form value 通知和内部 CascaderView.SelectedOptions。

4.2 展开流

CascaderViewItem.IsExpanded changed
      ↓
CascaderView.HandleCascaderItemExpanded
      ↓
ExpandItemAsync(option)
      ↓
build root-to-target path
      ↓
collapse sibling expanded branch
      ↓
create child CascaderViewLevelList from option.Children

展开目标必须能从当前 root options 中找到完整路径。找不到 root 或路径不完整时,控件把它视为不属于当前 CascaderView 的野数据。

4.3 勾选流

CascaderViewItem.IsChecked changed
      ↓
CheckedSubTree / UnCheckedSubTree
      ↓
MarkViewOptionChecked(option)
      ↓
SetupParentNodeCheckedStatus
      ↓
CascaderView.SelectedOptions
      ↓
Cascader.SelectedOptions
      ↓
EffectiveSelectedOptions

父级状态按有效子节点计算。有效子节点必须同时满足 enabled 和 checkbox enabled。父级结果为 true、false 或 null,其中 null 表达半选。

4.4 过滤流

SelectFilterTextBox.TextChanged
      ↓
Cascader.FilterValue
      ↓
CascaderView.FilterItems
      ↓
collect leaf path infos
      ↓
Filter.Filter(selector(pathInfo), FilterValue)
      ↓
CascaderViewFilterList

过滤结果只收集叶子路径。结果项的 Content 是 header 路径文本,ExpandItems 是完整 option 路径,选择结果时回写末级 option。

4.5 绑定型选项同步流

CascaderViewItem prepared
      ↓
BindableCascaderOption.AttachResourceHost(owner)
      ↓
option.PropertyChanged -> container state
option.Children.CollectionChanged -> container leaf state
container IsChecked / IsExpanded changed -> option state
      ↓
container clear / detach -> dispose attachment and subscriptions

Header 的容器值保持为 option 对象本身。默认 OptionTemplate 通过 Header 绑定显示文本,用户自定义模板也继续以 option 为 DataContext。

5. 生命周期与模板接入

Cascader 静态构造注册清除、过滤输入、tag close、multiple、OptionsSource 和选择属性变化的 class handler。

Cascader 构造时注册 Cascader token scope,并监听 Options.CollectionChanged。OnInitialized() 设置默认 contains filter。OnLoaded() 应用 DefaultSelectOptionPath。

Cascader.OnApplyTemplate() 重新应用模板时必须先解除旧 _cascaderView 的事件订阅,并清空旧 view 的 OptionsSource。随后获取 PART_SingleFilterInput 和 PART_CascaderView,重新订阅内部 view 事件并把 Options 转发给 view。

CascaderView.OnApplyTemplate() 获取 PART_ItemsPanel、PART_RootLevelList 和 PART_FilterList。Root level list 设置 Level=1 和 OwnerView=this,filter list 必须解除旧 selection 订阅再订阅新实例。

CascaderView 分别持有树列和过滤列的 active candidate owner。普通树列的 pointer move 先把 enabled item 提升为 active candidate,再按 ExpandTrigger 执行展开;过滤列 pointer move 同样迁移过滤候选,不再无条件清除。过滤结果重建或清空、filter list 重套模板、popup 关闭时 ResetInteractionState() 清除两类候选并折叠展开路径。虚拟化树容器回收时必须清除 IsCandidateSelectedProperty,不能让候选视觉进入下一个 option 的复用容器。

CascaderViewLevelList.ContainerForItemPreparedOverride() 对每个 ICascaderOption 调用 CascaderViewItem.PrepareCascaderOptionData()。如果 option 是 BindableCascaderOption,容器创建同一生命周期的 CompositeDisposable,用于保存 resource host attachment、属性订阅和 children 集合订阅。

ClearContainerForItemOverride() 保存虚拟化上下文后,必须先释放绑定型选项订阅,再清空容器属性。这个顺序防止容器清理动作把空值反写到用户数据。

CascaderViewItem.OnDetachedFromVisualTree() 释放当前绑定型选项生命周期,防止 detached 容器继续持有 option 或 owner resource host。

CascaderView.OnDetachedFromVisualTree() 取消所有待处理异步加载。

6. 交互与事件处理

Pointer 路径:

  • 外层 Cascader 在 popup 外 pointer release 时切换 IsDropDownOpen。
  • popup 内 pointer 事件不触发外层关闭。
  • tag close button 点击不触发外层 popup toggle。
  • CascaderViewLevelList 点击 option 时先触发 item click,再按 ExpandTrigger 决定是否展开。
  • CascaderViewLevelList / CascaderViewFilterList 的 pointer move 只负责把命中的 enabled item 迁移为 active candidate;普通列的鼠标路径不滚动,过滤列不因 pointer move 无条件清除候选。

键盘路径:

  • popup 打开时,按键先交给 _cascaderView.HandleKeyDown()。
  • 过滤模式的 Up / Down 由 CascaderViewFilterList 在 displayed result order 中循环查找 enabled item,并只更新 CandidateSelectedItem / CandidateSelectedIndex 和容器候选视觉;Enter 通过 GetCandidateOrFirstEnabledItem() 取得当前候选或第一个 enabled result,再进入与 pointer selection 相同的 TrySelectFilterResult() 提交流程。
  • 树模式的 Up / Down 从当前所有已展开列收集 realized、enabled 的 CascaderViewItem 并循环移动 _keyboardCandidateItem。Right 展开当前或第一个候选并在布局完成后进入第一个 enabled child;Left 从子级返回 parent,位于展开根节点时折叠;Enter 提交 enabled、非 loading 的 leaf 或允许选择的 parent,否则执行同一展开/进入子级流程。
  • 两类候选都只写 IsCandidateSelected 高亮,不写 SelectedIndex、SelectedOption 或 SelectedOptions;真实选择只在 Enter 或 pointer 提交路径发生。
  • Escape 关闭 popup。
  • popup 关闭时,Down 打开 popup,F4 切换 popup。

单选路径:

  • leaf item selected 或 clicked 时提交 SelectedOption。
  • IsAllowSelectParent=true 时,非 leaf option 也可提交。
  • 单选 popup 关闭时清空过滤输入和 FilterValue。

多选路径:

  • IsMultiple=true 时,CascaderViewItem.CheckedEvent 触发 subtree check / uncheck。
  • SelectedOptionsChanged 从内部 view 同步到外层 Cascader。
  • 外部 SelectedOptions 替换或原地集合变化同步到内部 view 时按差量更新,只勾选新增项、取消移除项,避免先清空再重选造成状态闪烁。
  • tag close 通过 SelectTag.ClosedEvent 移除目标 option 及其子孙。

过滤路径:

  • SelectFilterTextBox.TextChangedEvent 更新 FilterValue。
  • CascaderViewFilterList.SelectionChanged 选择过滤结果,并在单选模式下提交目标 option。

7. 内部算法与关键流程

7.1 路径 identity

BuildOptionIdentityPath() 从当前 option 沿 ParentNode 回溯到 root。每一级路径段优先使用 ItemKey,缺失时使用 Value?.ToString()。任一级没有可用段时,整条路径不可用于重映射。

TryParseSelectPath() 按路径段逐级在当前 options 中查找,匹配规则同样是 ItemKey 优先、Value 兜底。

7.2 默认选择路径

DefaultSelectOptionPath 只在单选模式生效。它应用时生成 SelectedOptionPath 显示文本,但不直接把末级 option 写入 SelectedOption,直到用户交互或外部选择状态进入真实选择流。

7.3 展开路径

ExpandItemAsync(ICascaderOption) 先从目标节点沿 ParentNode 回溯到 root,构造路径。只有路径 root 存在于当前 _options 中时才继续展开。

每一级展开前会折叠同级已展开项,保证级联列只表达一条当前展开路径。子列由目标节点 Children 创建,OwnerView、ItemTemplate、IsAllowSelectParent 和 ExpandTrigger 从 CascaderView 继承。

7.4 Leaf 判断

容器 leaf 状态由 CascaderViewItem.ConfigureIsLeaf() 统一判断。非异步模式下主要按 children 是否为空判断;异步模式下,option.IsLeaf、AsyncLoaded 和 children 状态共同决定是否显示展开入口。

绑定型选项的 IsLeaf、Children 属性替换或当前 children 集合内容变化会触发容器重新执行 leaf 判断,不直接绕过 Cascader 的异步加载语义。

7.5 勾选父级状态

父级勾选状态按有效子节点计算。有效子节点必须同时满足 enabled 和 checkbox enabled。父级结果为:

  • 全部有效子节点 checked:true。
  • 任一有效子节点 checked 或 indeterminate:null。
  • 无有效子节点 checked:false。

7.6 多选展示策略

BuildEffectiveSelectedOptions() 根据 ShowCheckedStrategy 从真实 SelectedOptions 计算展示集合:

  • All 使用真实集合的展示快照。
  • ShowParent 保留完整覆盖子节点的父级,隐藏其子孙。
  • ShowChild 只保留没有 children 的节点。

该计算只影响 tag 展示,不改写真实选中集合。展示快照会在真实集合替换或原地变化时重建,保证 tag 区域不会因为集合实例未变化而保留旧内容。

7.7 过滤路径缓存

FilterItems() 首次过滤时收集所有叶子路径到 _allPathInfos,按路径文本排序并缓存。过滤值、过滤器或选项源变化时会重新过滤;ClearFilter() 清理过滤状态、结果和缓存。

7.8 绑定型选项资源宿主

BindableCascaderOption 不持有容器或 ShowCase。容器准备时临时 attach 到 CascaderView 或当前 level list 的 IResourceHost,清理时释放 attach。动态资源查找使用 owner resource 优先,再 fallback 到 Application。

8. 资源、性能与 AOT 边界

绑定型选项使用 source generator 生成 scoped resource host,不手写 IResourceHost / IThemeVariantHost 样板代码,不使用运行时反射扫描或字符串 binding。

模板内稳定关系优先放在 AXAML 中表达。右侧 count、content add-on 和 handle 状态使用 compiled ancestor binding;C# 属性同步只用于运行时生成容器和绑定型非 Visual 数据对象。

容器生命周期是绑定型选项的释放边界。CascaderViewItem 持有的 resource host attachment、option property subscription、container property subscription 和 children collection subscription 必须在容器 clear 或 detach 时释放。

异步加载由 AsyncExpandLoadCoordinator 合并同一节点并发请求并应用超时。CascaderView detached 时必须调用 CancelAll(),避免已离开视觉树的控件继续处理加载结果。

过滤路径缓存以当前 options 为边界,清除过滤或 options source 变化时必须失效,不能跨数据源复用旧路径。

9. 维护不变量

  • 不把 CascaderOption 改造成 AvaloniaObject。
  • 新增 binding target 需求时使用独立 bindable 类型。
  • 容器清理时先释放订阅,再清空容器属性。
  • Header 容器值保持 option 对象,不改成 header 文本。
  • checked / expanded 的反写只针对 BindableCascaderOption,不改变普通 CascaderOption 的 CLR 数据模型语义。
  • ShowCheckedStrategy 不能改写真实 SelectedOptions。
  • DefaultSelectOptionPath 和 OptionsSource 重映射必须继续使用 ItemKey 优先、Value 兜底的路径 identity。
  • 展开、过滤、异步加载和勾选不能通过延迟刷新或抑制标记掩盖状态所有权问题。
  • 重新套用模板、container recycle、ItemsSource replacement 和 detach 都必须有对应 release 路径。

10. 测试与验证

关键测试:

  • CascaderBindableOptionTests:绑定型选项 parent 维护、容器同步、children 变化、checked / expanded 反写、owner resource 和 WeakReference 生命周期。
  • CascaderLazyLoadTests:异步加载时弹层保持打开、loading 和 loaded children。
  • CascaderChangeOnSelectTests:父级可选时立即选择并保持弹层打开。
  • CascaderThemeContractTests:主题 binding 和模板契约。

验证命令:

dotnet test tests/AtomUI.Desktop.Controls.Tests/AtomUI.Desktop.Controls.Tests.csproj --framework net10.0 --no-restore --filter FullyQualifiedName~Cascader
dotnet build controlgallery/AtomUIGallery/AtomUIGallery.csproj --no-restore
git diff --check