// Cascader.cs using System.Collections; using System.Windows.Controls.Primitives; using System.Windows.Input; using Melskin.Utilities; namespace Melskin.Controls; /// /// 用于实现级联选择器功能的控件类。该控件允许用户从多层级的数据源中选择一个值,通常应用于需要按类别或层级进行选择的场景。 /// [TemplatePart(Name = "PART_Popup", Type = typeof(Popup))] public class Cascader : Control { private static readonly DependencyPropertyKey FilteredItemsSourcePropertyKey = DependencyProperty.RegisterReadOnly("FilteredItemsSource", typeof(IEnumerable), typeof(Cascader), new PropertyMetadata(null)); /// /// 用于获取经过筛选后的项集合。此属性为只读,其值由控件内部逻辑根据当前搜索条件自动更新。 /// public static readonly DependencyProperty FilteredItemsSourceProperty = FilteredItemsSourcePropertyKey.DependencyProperty; /// /// 控制是否启用搜索功能的开关 /// public static readonly DependencyProperty IsSearchableProperty = DependencyProperty.Register(nameof(IsSearchable), typeof(bool), typeof(Cascader), new PropertyMetadata(true)); /// /// 定义一个依赖属性,用于接收来自 XAML 的 ListBox 样式 /// public static readonly DependencyProperty PanelStyleProperty = DependencyProperty.Register(nameof(PanelStyle), typeof(Style), typeof(Cascader), new PropertyMetadata(null)); /// /// 用于存储或获取搜索框中的文本,支持双向数据绑定。 /// public static readonly DependencyProperty SearchTextProperty = DependencyProperty.Register(nameof(SearchText), typeof(string), typeof(Cascader), new FrameworkPropertyMetadata("", FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSearchTextChanged)); private readonly List selectedPath = []; static Cascader() { DefaultStyleKeyProperty.OverrideMetadata(typeof(Cascader), new FrameworkPropertyMetadata(typeof(Cascader))); } /// /// Cascader 控件用于在用户界面中显示级联选择菜单。该控件支持从数据源动态加载选项,并允许用户通过多级菜单进行选择。 /// /// /// 该控件包含多个依赖属性,如 ItemsSource、SelectedValue 和 DisplayMemberPath 等,以支持数据绑定和自定义显示。 /// 另外,Cascader 控件还支持搜索功能(通过 IsSearchable 属性控制),并且可以自定义弹出面板的样式(通过 PanelStyle 属性)。 /// public Cascader() { SelectSearchResultCommand = new RelayCommand(ExecuteSelectSearchResult); } private ListBox CreateItemsControl(IEnumerable items) { var itemsControl = new ListBox { Style = PanelStyle, ItemsSource = items, //DisplayMemberPath = DisplayMemberPath }; itemsControl.SelectionChanged += OnSelectionChanged; return itemsControl; } private void ExecuteSelectSearchResult(CascaderSearchResult? result) { if (result == null) return; selectedPath.Clear(); selectedPath.AddRange(result.FullPath); SelectedValue = result.TargetItem; UpdateSelectedText(); IsDropDownOpen = false; SearchText = ""; } private IEnumerable? GetChildren(object? item) { if (item == null || string.IsNullOrEmpty(SubmenuMemberPath)) return null; var property = item.GetType().GetProperty(SubmenuMemberPath); return property?.GetValue(item) as IEnumerable; } private string GetObjectDisplayText(object? item) { if (item == null) return string.Empty; if (!string.IsNullOrEmpty(DisplayMemberPath)) { var prop = item.GetType().GetProperty(DisplayMemberPath); return prop?.GetValue(item)?.ToString() ?? string.Empty; } return item.ToString()!; } // 创建了完整的、非空的 CascadingPanel private void InitializeCascadingPanel() { selectedPath.Clear(); UpdateSelectedText(); // 1. 创建一个水平 StackPanel 作为根容器。这就是 CascadingPanel 的实体。 var panel = new StackPanel { Orientation = Orientation.Horizontal }; if (ItemsSource != null && ItemsSource.Cast().Any()) { // 2. 创建第一级菜单 (ListBox) var firstLevelMenu = CreateItemsControl(ItemsSource); // 3. 将第一级菜单添加到 Panel 中 panel.Children.Add(firstLevelMenu); } // 4. 将这个构建好的、包含内容的 Panel 赋值给依赖属性,让 XAML 可以找到它。 CascadingPanel = panel; } private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var control = (Cascader)d; control.InitializeCascadingPanel(); control.UpdateFilteredItems(); } private static void OnSearchTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var control = (Cascader)d; control.UpdateFilteredItems(); } private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) { if (e.AddedItems.Count == 0) return; var selectedItem = e.AddedItems[0]!; var currentMenu = sender as ItemsControl; var parentPanel = CascadingPanel; if (parentPanel == null) return; var currentIndex = parentPanel.Children.IndexOf(currentMenu); while (selectedPath.Count > currentIndex) selectedPath.RemoveAt(selectedPath.Count - 1); selectedPath.Add(selectedItem); while (parentPanel.Children.Count > currentIndex + 1) parentPanel.Children.RemoveAt(parentPanel.Children.Count - 1); var children = GetChildren(selectedItem); if (children != null && children.Cast().Any()) { var newMenu = CreateItemsControl(children); parentPanel.Children.Add(newMenu); UpdateSelectedText(); } else { SelectedValue = selectedItem; UpdateSelectedText(); IsDropDownOpen = false; } } private void SearchRecursive(IEnumerable? nodes, List currentPath, List results) { if (nodes == null) return; foreach (var node in nodes) { var newPath = new List(currentPath) { node }; var displayText = GetObjectDisplayText(node); if (displayText.IndexOf(SearchText, StringComparison.OrdinalIgnoreCase) >= 0) { var fullPathText = string.Join(Separator, newPath.Select(GetObjectDisplayText)); results.Add(new CascaderSearchResult(fullPathText, node, newPath)); } var children = GetChildren(node); if (children != null) { SearchRecursive(children, newPath, results); } } } private void UpdateFilteredItems() { if (string.IsNullOrWhiteSpace(SearchText) || ItemsSource == null) { FilteredItemsSource = null; return; } var results = new List(); SearchRecursive(ItemsSource, [], results); FilteredItemsSource = results; } private void UpdateSelectedText() { SelectedText = string.Join(Separator, selectedPath.Select(GetObjectDisplayText)); } /// public override void OnApplyTemplate() { base.OnApplyTemplate(); InitializeCascadingPanel(); } /// /// /// public IEnumerable? FilteredItemsSource { get => (IEnumerable)GetValue(FilteredItemsSourceProperty); protected set => SetValue(FilteredItemsSourcePropertyKey, value); } /// /// 获取或设置一个值,指示是否允许搜索功能。当设置为 true 时,用户可以使用搜索框来过滤选项;如果设置为 false,则禁用搜索功能。 /// public bool IsSearchable { get => (bool)GetValue(IsSearchableProperty); set => SetValue(IsSearchableProperty, value); } /// /// 用于设置或获取级联控件面板的样式 /// public Style PanelStyle { get => (Style)GetValue(PanelStyleProperty); set => SetValue(PanelStyleProperty, value); } /// /// 用于存储或获取当前搜索框中的文本,支持双向绑定。 /// public string SearchText { get => (string)GetValue(SearchTextProperty); set => SetValue(SearchTextProperty, value); } /// /// 用于选择搜索结果的命令。此命令绑定到UI元素上,当用户触发时,将执行与选择搜索结果相关的操作。 /// public ICommand SelectSearchResultCommand { get; private set; } #region 依赖属性 /// /// 用于设置或获取级联选择器的数据源。 /// public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register( nameof(ItemsSource), typeof(IEnumerable), typeof(Cascader), new PropertyMetadata(null, OnItemsSourceChanged)); /// /// 获取或设置用于显示的项集合。 /// public IEnumerable ItemsSource { get => (IEnumerable)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } /// /// 用于获取或设置当前选中的值。此属性支持双向数据绑定。 /// public static readonly DependencyProperty SelectedValueProperty = DependencyProperty.Register( nameof(SelectedValue), typeof(object), typeof(Cascader), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); /// /// 获取或设置当前选中的值。 /// 该属性支持双向数据绑定,可以用于在Cascader控件与其数据源之间同步选定的项。 /// public object SelectedValue { get => GetValue(SelectedValueProperty); set => SetValue(SelectedValueProperty, value); } /// /// 用于指定在显示项时使用的属性路径。 /// public static readonly DependencyProperty DisplayMemberPathProperty = DependencyProperty.Register(nameof(DisplayMemberPath), typeof(string), typeof(Cascader), new PropertyMetadata("")); /// /// 用于指定在显示项时,用来表示对象的属性路径。当设置此属性后,控件将使用该属性的值来展示列表中的每个项目。 /// public string DisplayMemberPath { get => (string)GetValue(DisplayMemberPathProperty); set => SetValue(DisplayMemberPathProperty, value); } /// /// 用于指定子菜单成员路径的属性,该属性指示了在数据源中如何查找子项。 /// public static readonly DependencyProperty SubmenuMemberPathProperty = DependencyProperty.Register( nameof(SubmenuMemberPath), typeof(string), typeof(Cascader), new PropertyMetadata("Children")); /// /// 用于指定子菜单项的属性路径,该属性路径指向的数据应为可枚举类型,表示当前项下的子项集合。 /// public string SubmenuMemberPath { get => (string)GetValue(SubmenuMemberPathProperty); set => SetValue(SubmenuMemberPathProperty, value); } /// /// 用于获取或设置与控件关联的输入绑定集合 /// public static readonly DependencyProperty InputBindingsProperty = DependencyProperty.RegisterAttached("InputBindings", typeof(InputBindingCollection), typeof(Cascader), new FrameworkPropertyMetadata(new InputBindingCollection(), OnInputBindingsChanged)); /// /// 获取指定元素的输入绑定集合。 /// /// 要获取输入绑定集合的UI元素。 /// 返回指定元素的输入绑定集合。 public static InputBindingCollection GetInputBindings(UIElement element) => (InputBindingCollection)element.GetValue(InputBindingsProperty); /// /// 设置指定元素的输入绑定集合。 /// /// 要设置输入绑定的UI元素。 /// 要分配给元素的输入绑定集合。 public static void SetInputBindings(UIElement element, InputBindingCollection value) => element.SetValue(InputBindingsProperty, value); private static void OnInputBindingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not UIElement uiElement) return; uiElement.InputBindings.Clear(); if (e.NewValue is InputBindingCollection bindings) { uiElement.InputBindings.AddRange(bindings); } } /// /// 用于设置或获取分隔符字符串,该字符串用于在显示层级结构时分隔不同层级的文本。 /// public static readonly DependencyProperty SeparatorProperty = DependencyProperty.Register(nameof(Separator), typeof(string), typeof(Cascader), new PropertyMetadata(" / ")); /// /// 用于连接路径中各个元素的分隔符字符串 /// public string Separator { get => (string)GetValue(SeparatorProperty); set => SetValue(SeparatorProperty, value); } /// /// 控制下拉菜单是否展开的属性 /// public static readonly DependencyProperty IsDropDownOpenProperty = DependencyProperty.Register( nameof(IsDropDownOpen), typeof(bool), typeof(Cascader), new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)); /// /// 获取或设置一个值,表示下拉菜单是否处于打开状态。此属性支持双向数据绑定。 /// public bool IsDropDownOpen { get => (bool)GetValue(IsDropDownOpenProperty); set => SetValue(IsDropDownOpenProperty, value); } private static readonly DependencyPropertyKey SelectedTextPropertyKey = DependencyProperty.RegisterReadOnly("SelectedText", typeof(string), typeof(Cascader), new PropertyMetadata("")); /// /// 获取或设置当前选中的文本。 /// public static readonly DependencyProperty SelectedTextProperty = SelectedTextPropertyKey.DependencyProperty; /// /// 获取或设置当前选中的文本,该文本通常由用户选择的级联项组成。 /// public string SelectedText { get => (string)GetValue(SelectedTextProperty); protected set => SetValue(SelectedTextPropertyKey, value); } // 【核心概念】这里定义了 CascadingPanel 属性,它是一个“舞台”的蓝图 internal static readonly DependencyProperty CascadingPanelProperty = DependencyProperty.Register(nameof(CascadingPanel), typeof(Panel), typeof(Cascader), new PropertyMetadata(null)); internal Panel CascadingPanel { get => (Panel)GetValue(CascadingPanelProperty); set => SetValue(CascadingPanelProperty, value); } #endregion } /// /// 用于封装级联选择器搜索结果的类 /// /// /// 用于封装级联选择器搜索结果的类。此类包含了展示文本、目标项以及完整的路径信息。 /// public class CascaderSearchResult(string displayText, object targetItem, List fullPath) { /// /// 用于在UI上显示的完整路径文本 /// public string DisplayText { get; } = displayText; /// /// 构成完整路径的原始数据对象列表 /// public List FullPath { get; } = fullPath; /// /// 路径中的最后一项,即匹配到的原始数据对象 /// public object TargetItem { get; } = targetItem; }