Files
ShrlAlgoToolkit/Melskin/Controls/Cascader.xaml.cs

421 lines
16 KiB
C#
Raw Normal View History

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