Files
ShrlAlgoToolkit/Melskin/Controls/Cascader.xaml.cs
2026-02-23 15:05:15 +08:00

421 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Cascader.cs
using System.Collections;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using Melskin.Utilities;
namespace Melskin.Controls;
/// <summary>
/// 用于实现级联选择器功能的控件类。该控件允许用户从多层级的数据源中选择一个值,通常应用于需要按类别或层级进行选择的场景。
/// </summary>
[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));
/// <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)
{
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<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;
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<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;
}