13 KiB
Select 桌面版实现原理
本文档描述 Select 桌面版的输入壳体、选项集合、选择同步、过滤、Tags 动态选项、候选弹层、异步加载、Form 和 Token 资源边界。公共设计与 API 契约见 Select 桌面版架构设计,Token 语义见 Select Token 设计,变化记录见 Select Changelog。
1. 实现定位
Select 的实现以 AbstractSelect 为输入与弹层基类,Select 本体负责选择业务状态,候选列表和结果区域由内部控件承载。实现文档聚焦 Select 自身的状态编排、生命周期和维护边界,不重新说明 ListView 的通用虚拟化、AddOnDecoratedBox 的通用输入外观或 PopupHost 的全局资源规则。
2. 源码文件结构
主要源码:
src/AtomUI.Desktop.Controls/Select/AbstractSelect.cs:输入壳体、弹层状态、公共输入属性、Form / CompactSpace / Motion 接口和 popup 生命周期。src/AtomUI.Desktop.Controls/Select/Select.cs:public Select API、protected 扩展 hook、选项同步、选择同步、过滤输入、Tags 动态选项、键盘和指针处理。src/AtomUI.Desktop.Controls/Select/Select.AsyncOptionsLoad.cs:异步候选加载和私有加载完成流程。src/AtomUI.Desktop.Controls/Select/SelectOption.cs:ISelectOption和默认SelectOption。src/AtomUI.Desktop.Controls/Select/SelectCandidateList.cs:候选列表、候选导航、提交取消、最大选择数和隐藏已选项。src/AtomUI.Desktop.Controls/Select/SelectCandidateListItem.cs:候选项容器状态。src/AtomUI.Desktop.Controls/Select/SelectResultOptionsBox.cs:多选结果标签和过滤输入承载。src/AtomUI.Desktop.Controls/Select/SelectHandle.cs:右侧展开、loading、清除和 Form feedback 图标。src/AtomUI.Desktop.Controls/Select/DataLoad/*:异步候选加载接口、结果和事件参数。src/AtomUI.Desktop.Controls/Select/SelectToken.cs:Select 组件 Token。src/AtomUI.Desktop.Controls/Select/Themes/*.axaml:Select 根模板、候选列表、结果标签、handle、输入壳体和 token 样式。
3. 核心类职责
AbstractSelect 是 Select 家族的输入与弹层边界。它维护 IsDropDownOpen、popup placement、弹层打开/关闭事件、打开期间可见性订阅、TopLevel 失活订阅、Form 状态映射和 CompactSpace 边框厚度计算。
Select 是选项选择协调器。它维护 Options、SelectedOption、SelectedOptions、Mode、过滤值、默认值映射、Tags 动态选项和候选列表同步。它不直接绘制候选项或标签,而是把状态传给内部控件。
SelectCandidateList 是候选选择边界。它继承 ListView,负责过滤、分组、虚拟化容器、候选项键盘导航、最大选择数禁用和 Commit/Cancel 事件。
SelectResultOptionsBox 是多选结果边界。它根据 SelectedOptions 创建 SelectTag,并在过滤启用时插入 SelectFilterTextBox。
SelectHandle 是右侧操作边界。它根据 loading、过滤、展开状态选择当前图标,并把清除按钮点击转换为 ClearRequestedEvent。
4. 状态与数据流
初始化流:
Select()
→ RegisterTokenResourceScope(SelectToken)
OnInitialized()
→ if FilterValueSelector == null, use HeaderFilterPropertySelector
OnLoaded()
→ ConfigureDefaultValues()
选项源流:
OptionsSource changed
→ remember current selection identity
→ Options.Clear() / Options.SetItemsSource(new source)
→ remap selected option by ItemKey or Content
→ if no remap, ConfigureDefaultValues()
选择同步流:
CandidateList.Commit / SelectionChanged
→ SelectedOption or SelectedOptions
→ NotifyFormValueChanged
→ SelectionChanged event
→ sync CandidateList.SelectedItem / SelectedItems
→ update SelectedCount / placeholder / result visible state
过滤流:
SelectFilterTextBox.TextChanged
→ FilterValue = trimmed text or null
→ CandidateList filters by Filter + FilterValue + FilterValueSelector
→ Tags mode may create dynamic option
→ Dispatcher.Post(SyncSelectionToCandidateList)
Form 流:
Form.SetValue(object?)
→ Single: SelectedOption
→ Multiple/Tags: SelectedOptions
Form.GetValue()
→ Single: SelectedOption
→ Multiple/Tags: SelectedOptions
Form.ClearValue()
→ clear current selection
ValidateStatus
→ Status
FeedbackControl
→ FormFeedback → SelectHandle
5. 生命周期与模板接入
AbstractSelect.OnApplyTemplate() 负责稳定模板 part 接入:
- 重新计算 popup 最大高度。
- 解除旧
Popup.Opened/Popup.Closed订阅。 - 获取新的
PART_Popup和PART_AddOnDecoratedBox。 - 订阅新 popup 的 opened / closed。
Select.OnApplyTemplate() 在基类接入前调用 ClearPopupContent(),确保旧候选列表、popup frame 和事件订阅被释放。基类接入后,Select 获取 PART_SingleFilterInput,设置 Popup.OverlayInputPassThroughElement,并重新配置 placeholder、选择空状态、单选结果、伪类、过滤输入、有效搜索状态和 SelectHandle 输入状态 binding。
候选弹层内容是懒创建:
OpenDropDown()
→ EnsurePopupContent()
→ create PopupFrame if null
→ create SelectCandidateList if null
→ bind candidate list to Select properties
→ subscribe SelectionChanged / Commit / Cancel
→ Popup.Child = PopupFrame
ClearPopupContent() 必须解除 _candidateList.SelectionChanged、Commit、Cancel,清空 ItemsSource,移除 TemplatedParent,断开 PopupFrame.Child 和 Popup.Child,并清空 _candidateListActivated。
Popup 打开期间,AbstractSelect.PopupOpened() 创建 SubscriptionsOnOpen,订阅自身和祖先可见性变化;Popup 关闭时清空这些订阅。控件 detach 时释放 TopLevel deactivation 订阅,窗口失活时关闭弹层。
6. 交互与事件处理
键盘路径:
Select.OnKeyDown()先让已打开的_candidateList.HandleKeyDown()处理候选导航、提交和取消。F4、Alt+Up/Down切换弹层。- 弹层关闭时
Up/Down、Enter、Space打开弹层。 - 弹层打开时
Escape关闭弹层。 SelectResultOptionsBox.KeyDownEvent在 tunnel / bubble 阶段进入HandleFilterInputKeyDown(),用于多选删除最后一个 tag。
指针路径:
- popup 内部点击不关闭弹层。
- 多选 tag 的关闭按钮点击不触发外层打开/关闭切换。
- 控件按下时设置标准
:pressed,释放时根据状态打开或关闭弹层。
候选提交:
SelectCandidateList.Commit
→ Single: SelectedOption = CandidateList.SelectedItem
→ Multiple/Tags: SelectedOptions = CandidateList.SelectedItems copy
→ IsDropDownOpen = false
候选取消:
SelectCandidateList.Cancel
→ IsDropDownOpen = false
7. 内部算法与关键流程
7.1 默认值映射
ConfigureDefaultValues() 只在当前选择为空时使用 DefaultValues。单选取第一个默认值,多选和 Tags 遍历所有默认值。比较顺序为:
DefaultValueCompareFn(defaultValue, option)
else defaultValue.ToString() == option.Content?.ToString()
7.2 选项替换后的选择保留
OptionsSource 变化前,控件先把当前选择转换为稳定 identity:
option.ItemKey?.ToString()
else option.Content?.ToString()
新选项集合写入 Options 后,控件按 identity 查找新 ISelectOption 实例,尽量保留用户选择。找不到匹配项时清空选择或回到默认值映射。
7.3 Tags 动态选项
Tags 模式下,搜索文本非空且当前候选列表没有结果时,控件创建 IsDynamicAdded=true 的 SelectOption 并加入 Options。如果该动态选项未被选中且输入内容不再匹配,或者选中项变化后不再包含它,控件从 Options 中移除。
7.4 最大选择数
SelectCandidateList.ConfigureOptionsForMaxCount() 在 SelectedItems.Count >= MaxCount 时禁用所有未选候选项,已选候选项保持启用以允许取消。未达到上限时,容器启用状态回到 ISelectOption.IsEnabled。
7.5 弹层尺寸
ConfigureMaxDropdownHeight() 使用:
MaxPopupHeight = ItemHeight * DisplayPageSize
+ PopupContentPadding.Top
+ PopupContentPadding.Bottom
ConfigurePopupMinWith() 在 IsPopupMatchSelectWidth=true 时把 EffectivePopupWidth 设置为 Select 当前宽度,否则设置为 0。
7.6 右侧内容和 handle binding
Select 根模板中的稳定 template part 关系优先由 AXAML 表达。右侧内容区域位于 SelectAddOnDecoratedBox.ContentRightAddOn 内容边界内,内部节点不能依赖 TemplateBinding 取得 Select,因此使用 $parent[atom:Select] compiled ancestor binding:
SelectMaxCountIndicator通过 compiled ancestor binding 接收MaxCount、SelectedCount和IsShowMaxCountIndicator。PART_ContentRightAddOnPresenter通过 compiled ancestor binding 接收ContentRightAddOn、ContentRightAddOnTemplate,并用ObjectConverters.IsNotNull控制显示。SelectHandle通过 compiled ancestor binding 接收 Form feedback、loading 图标、展开图标、过滤状态、启用状态、motion、loading、clear、选择为空和下拉打开状态。
SetupSelectHandleInputStateBindings() 只保留 AddOnDecoratedBox 到 SelectHandle 的 hover / pressed 状态转发。这个关系的 source 是同一模板中的 sibling part,不是 templated parent,无法用 TemplateBinding 或 selector 在不改变模板契约的前提下表达。该 binding 在每次重新应用模板前释放旧 CompositeDisposable,生命周期与模板 part 获取路径一致。
7.7 内部抑制状态
Select 有两个内部抑制状态:
IgnorePropertyChange用于内部恢复IsDropDownOpen时跳过一次 property changed 分发。_ignoreSyncSelection用于候选列表提交时跳过一次 public selection 到候选列表的回写。
这两个状态必须通过成对 helper 进入和退出。即使目标属性没有发生实际变化,helper 也必须恢复抑制状态,避免下一次外部属性变化被误吞。
8. 资源、性能与 AOT 边界
Select 不依赖运行时反射发现模板结构。模板协作通过固定 template part、显式类型、属性绑定和事件完成。
资源和生命周期边界:
SubscriptionsOnOpen只在 popup 打开期间持有可见性订阅,popup 关闭时清空。_deactivationSubscription在 attach 时创建,detach 时释放。_selectHandleInputStateBindings每次模板接入前释放旧绑定,仅持有 AddOnDecoratedBox → SelectHandle 的 hover / pressed sibling part 状态转发。_candidateList的事件订阅和ItemsSource必须在ClearPopupContent()中释放。SelectHandle订阅FormFeedback.ValidateStatus时必须在 feedback 变化和 logical detach 时释放。- 异步加载通过
AsyncSearchLoadCoordinator处理超时、取消和跳过旧结果。
AOT 边界:
SelectToken通过 token generator 显式注册,生成SelectTokenKind和SelectTokenResourceExtension。- Gallery API / Token 表使用显式 ViewModel 数据。
OptionTemplate、AddOn 模板和 EmptyIndicator 模板是 XAML 模板入口,不依赖运行时成员扫描。
9. 维护不变量
内部重构必须保持以下不变量:
AbstractSelect继续持有输入壳体、弹层、Form、CompactSpace 和 Motion 的基础契约。Select继续持有选择、过滤、Tags 动态选项和异步加载状态。OptionsSource写入不能破坏Options的内容集合语义。- 选择同步中的
_ignoreSyncSelection只用于防止候选列表和 public selection 相互递归,必须通过成对 helper 恢复,不能吞掉外部选择变化。 IgnorePropertyChange只用于内部恢复下拉开关状态,必须通过成对 helper 恢复,不能影响下一次外部IsDropDownOpen变化。Tags动态选项只在Tags模式创建和清理。- 单选过滤输入在弹层关闭时显示已选项文本,弹层打开且可过滤时清空为搜索输入。
- 多选搜索输入关闭弹层时只读并清空。
- 弹层取消事件必须能阻止打开或关闭。
- 重新套用模板和 detach 不能保留旧候选列表、旧 popup child、旧 template part 绑定或旧 TopLevel 订阅。
SelectToken不承载选项数据、过滤值、loading、选择集合、popup 打开状态或 Form 状态。
10. 测试与验证
验证范围:
SelectShowCasePageTests和 snapshot,覆盖 Gallery 示例、API 表和 Token 表。- 单选、多选、Tags 三种模式的选择、清除、默认值和过滤行为。
OptionsSource替换后的选择保留。OptionsLoader成功、超时、取消和错误通知。MaxCount、IsHideSelectedOptions、MaxTagCount、IsResponsiveTagMode。Large/Middle/Small/Custom尺寸下单选输入、多选 tag、handle 和 popup 对齐。- Form 校验状态、feedback 图标、CompactSpace 边框折叠和 popup open/close 事件取消。
- 文档改动至少运行
git diff --check并检查相对链接存在。