421 lines
16 KiB
C#
421 lines
16 KiB
C#
// 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;
|
||
}
|