using System.Collections; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics; using System.Windows.Controls.Primitives; using System.Windows.Input; namespace Melskin.Controls; /// /// MultiComboBox 控件提供了一个可自定义的组合框,允许用户从下拉列表中选择一个或多个项目。 /// /// /// 该控件支持多种选择模式,并且可以设置默认文本和过滤功能,以增强用户体验。 /// [DefaultEvent("SelectionChanged")] public class MultiComboBox : Control { private ListBox? listBox; private TextBox? filterTextBox; private bool isUpdatingSelection; #region 依赖属性 /// /// 获取或设置用于生成组合框项的数据源。 /// /// /// 该属性允许绑定到一个实现了IEnumerable接口的对象,作为数据源来填充组合框中的项。当数据源发生变化时,组合框会自动更新其显示的项。 /// public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register( nameof(ItemsSource), typeof(IEnumerable), typeof(MultiComboBox), new PropertyMetadata(null, OnItemsSourceChanged)); /// /// 获取或设置当前选中的项列表。 /// /// /// 该属性允许绑定到一个实现了IList接口的对象,用于存储和访问组合框中用户选择的多个项。通过设置此属性,可以实现双向数据绑定,从而在控件和数据源之间同步选中的项。 /// public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register( nameof(SelectedItems), typeof(IList), typeof(MultiComboBox), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemsChanged)); /// /// 获取或设置当前选中的项。 /// /// /// 该属性允许绑定到一个对象,表示在组合框中当前被选中的项。通过设置此属性,可以控制和获取用户选择的项。此属性支持双向数据绑定。 /// public static readonly DependencyProperty SelectedItemProperty = DependencyProperty.Register( nameof(SelectedItem), typeof(object), typeof(MultiComboBox), new FrameworkPropertyMetadata( null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnSelectedItemChanged)); /// /// 获取或设置组合框的选择模式。 /// /// /// 该属性用于定义用户如何选择组合框中的项。可以设置为单选或多选模式,影响用户与控件交互的方式以及选定项的处理逻辑。 /// public static readonly DependencyProperty SelectionModeProperty = DependencyProperty.Register( nameof(SelectionMode), typeof(SelectionMode), typeof(MultiComboBox), new PropertyMetadata(SelectionMode.Multiple, OnSelectionModeChanged)); /// /// 获取或设置用于显示在组合框中的数据项的属性路径。 /// /// /// 该属性允许指定一个属性路径,用于从数据源中的每个对象提取要显示的文本。当设置了此属性后,组合框将使用该路径来获取每个项的显示文本。 /// public static readonly DependencyProperty DisplayMemberPathProperty = DependencyProperty.Register( nameof(DisplayMemberPath), typeof(string), typeof(MultiComboBox), new PropertyMetadata(string.Empty)); /// /// 获取或设置当没有选择任何项时显示的默认文本。 /// /// /// 该属性允许设置一个字符串,作为组合框在没有任何选项被选中时的占位符文本。默认值为"请选择..."。 /// public static readonly DependencyProperty PlaceHolderTextProperty = DependencyProperty.Register( nameof(PlaceHolderText), typeof(string), typeof(MultiComboBox), new PropertyMetadata("请选择...")); /// /// 获取或设置一个值,该值指示下拉列表是否处于打开状态。 /// /// /// 通过此属性可以控制组合框的下拉列表是否显示。当设置为true时,下拉列表将显示;当设置为false时,下拉列表将隐藏。此属性支持双向数据绑定。 /// public static readonly DependencyProperty IsDropDownOpenProperty = DependencyProperty.Register( nameof(IsDropDownOpen), typeof(bool), typeof(MultiComboBox), new FrameworkPropertyMetadata( false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsDropDownOpenChanged)); /// /// 获取或设置下拉列表的最大高度。 /// /// /// 该属性定义了下拉列表在显示时的最大高度。当设置一个值时,下拉列表的高度将不会超过这个值。默认情况下,最大高度为300.0。 /// public static readonly DependencyProperty MaxDropDownHeightProperty = ComboBox.MaxDropDownHeightProperty.AddOwner(typeof(MultiComboBox), new FrameworkPropertyMetadata(300.0)); /// /// 获取或设置用于过滤组合框项的文本。 /// /// /// 该属性允许用户输入文本以过滤组合框中的项。当FilterText发生变化时,组合框会根据输入的文本自动过滤显示的项。 /// public static readonly DependencyProperty FilterTextProperty = DependencyProperty.Register( nameof(FilterText), typeof(string), typeof(MultiComboBox), new PropertyMetadata(string.Empty, OnFilterTextChanged)); /// /// 获取或设置经过筛选后用于显示的项集合。 /// /// /// 该属性允许绑定到一个实现了IEnumerable接口的对象,作为经过筛选后的数据源来填充组合框中的显示项。当筛选条件发生变化时,通过此属性可以更新显示的项列表。 /// public static readonly DependencyProperty FilteredItemsProperty = DependencyProperty.Register( nameof(FilteredItems), typeof(IEnumerable), typeof(MultiComboBox), new PropertyMetadata(null)); /// /// 获取或设置一个布尔值,该值指示是否启用过滤功能。 /// /// /// 当此属性设置为true时,组合框将支持根据用户输入的文本对项进行过滤。这在处理大量数据项时特别有用,可以帮助用户快速找到所需的内容。 /// public static readonly DependencyProperty IsFilteringEnabledProperty = DependencyProperty.Register( nameof(IsFilteringEnabled), typeof(bool), typeof(MultiComboBox), new PropertyMetadata(true)); #endregion #region 公共属性 /// /// 获取或设置用于生成项列表的数据源。 /// /// /// 该属性允许绑定到一个数据源,该数据源可以是任何实现了IEnumerable接口的对象。通过设置此属性,可以动态地填充控件中的项列表。 /// public IEnumerable ItemsSource { get => (IEnumerable)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } /// /// 获取或设置当前选中的项目列表。 /// /// /// 该属性用于存储用户在组合框中选择的所有项目。当选择模式为多选时,此属性特别有用。 /// public IList SelectedItems { get => (IList)GetValue(SelectedItemsProperty); set => SetValue(SelectedItemsProperty, value); } /// /// 获取或设置当前选中的项。 /// /// /// 该属性用于绑定到数据源中的单个选定项。当用户选择一个新项时,此属性将更新为所选项的值。 /// 如果没有选中任何项,则此属性的值为 null。 /// public object? SelectedItem { get => GetValue(SelectedItemProperty); set => SetValue(SelectedItemProperty, value); } /// /// 获取或设置组合框的选择模式。 /// /// /// 该属性定义了用户如何选择项目。可以是单选或多选。 /// public SelectionMode SelectionMode { get => (SelectionMode)GetValue(SelectionModeProperty); set => SetValue(SelectionModeProperty, value); } /// /// 获取或设置用于显示列表中每个项目的属性路径。 /// /// /// 此属性允许指定一个字符串,该字符串作为数据绑定路径使用,以确定在组合框中如何显示每个项目。如果设置了此属性,则会根据给定的属性路径来展示集合中的项;否则,默认显示项的 ToString() 方法返回的值。 /// public string DisplayMemberPath { get => (string)GetValue(DisplayMemberPathProperty); set => SetValue(DisplayMemberPathProperty, value); } /// /// 获取或设置当组合框中没有选择任何项时显示的占位符文本。 /// /// /// 该属性允许自定义在用户尚未做出选择之前,组合框内显示的提示信息。默认值为"Please select...",但可以根据需要进行更改以提供更具体的指导或本地化支持。 /// public string PlaceHolderText { get => (string)GetValue(PlaceHolderTextProperty); set => SetValue(PlaceHolderTextProperty, value); } /// /// 获取或设置一个值,该值指示下拉列表是否打开。 /// /// /// 通过此属性可以控制组合框的下拉列表是否显示。当设置为 true 时,下拉列表将展开;设置为 false 时,下拉列表将关闭。 /// 此属性支持双向数据绑定,并且在下拉列表的状态发生变化时会自动更新。 /// public bool IsDropDownOpen { get => (bool)GetValue(IsDropDownOpenProperty); set => SetValue(IsDropDownOpenProperty, value); } /// /// 获取或设置下拉列表的最大高度。 /// /// /// 该属性定义了组合框下拉列表部分能够显示的最大高度。当设置此属性时,可以控制下拉列表的可见区域大小,防止在选项过多时占用过多屏幕空间。默认值为300.0。 /// public double MaxDropDownHeight { get => (double)GetValue(MaxDropDownHeightProperty); set => SetValue(MaxDropDownHeightProperty, value); } /// /// 获取或设置用于过滤组合框项的文本。 /// /// /// 该属性允许用户输入文本以过滤组合框中的项。当FilterText发生变化时,组合框会自动更新其显示的项,仅显示与过滤文本匹配的项。如果过滤文本不为空且下拉列表未打开,则会自动打开下拉列表。 /// public string FilterText { get => (string)GetValue(FilterTextProperty); set => SetValue(FilterTextProperty, value); } /// /// 获取或设置经过筛选后显示在组合框中的项。 /// /// /// 该属性包含了从数据源中筛选出的项,这些项将被实际显示在组合框中。通过设置此属性,可以控制哪些项最终展示给用户。当数据源发生变化或应用了新的筛选条件时,FilteredItems 会相应地更新。 /// public IEnumerable? FilteredItems { get => (IEnumerable)GetValue(FilteredItemsProperty); set => SetValue(FilteredItemsProperty, value); } /// /// 获取或设置一个值,该值指示是否启用过滤功能。 /// /// /// 当此属性设置为true时,组合框将允许用户通过输入文本进行过滤。如果设置为false,则禁用过滤功能。默认值为true。 /// public bool IsFilteringEnabled { get => (bool)GetValue(IsFilteringEnabledProperty); set => SetValue(IsFilteringEnabledProperty, value); } #endregion /// /// 当组合框中的选中项发生变化时触发的事件。 /// /// /// 此事件在用户更改了组合框中的选中项或通过代码更改了选中项时触发。可以用来响应用户的交互或程序逻辑的变化。 /// 事件处理程序接收一个 SelectionChangedEventArgs 参数,其中包含了旧的和新的选中项的信息。 /// public event SelectionChangedEventHandler? SelectionChanged; static MultiComboBox() { DefaultStyleKeyProperty.OverrideMetadata( typeof(MultiComboBox), new FrameworkPropertyMetadata(typeof(MultiComboBox))); } /// /// MultiComboBox 控件提供了一个下拉列表,用户可以从列表中选择一个或多个项目。 /// 该控件支持单选和多选模式,并且可以设置过滤功能。 /// public MultiComboBox() { SelectedItems = new ObservableCollection(); AddHandler(Controls.Tag.ClosedEvent, new RoutedEventHandler(OnTagClosed)); AddHandler( PreviewKeyDownEvent, new KeyEventHandler( (_, e) => { if (e.Key == Key.Back && SelectionMode == SelectionMode.Multiple && string.IsNullOrEmpty(FilterText) && SelectedItems is { Count: > 0 }) { SelectedItems.Remove(SelectedItems[SelectedItems.Count - 1]); e.Handled = true; } })); } /// public override void OnApplyTemplate() { base.OnApplyTemplate(); if (listBox != null) listBox.SelectionChanged -= ListBox_SelectionChanged; listBox = GetTemplateChild("PART_ListBox") as ListBox; filterTextBox = GetTemplateChild("PART_FilterTextBox") as TextBox; if (listBox != null) { listBox.SelectionChanged += ListBox_SelectionChanged; //_listBox.SelectionMode = SelectionMode; UpdateListBoxSelection(); } UpdateFilteredItems(); if (IsDropDownOpen) FocusFilterBoxIfNeeded(); } private static void OnIsDropDownOpenChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; if ((bool)e.NewValue) { c.Dispatcher.BeginInvoke(new Action(c.FocusFilterBoxIfNeeded)); } else { // 当下拉列表关闭时,清除过滤文本。 // 这有助于防止因为FilterText未清空而导致OnFilterTextChanged方法再次打开下拉列表。 if (!string.IsNullOrEmpty(c.FilterText)) { c.FilterText = string.Empty; } } } private void FocusFilterBoxIfNeeded() { if (IsFilteringEnabled && filterTextBox != null) { filterTextBox.Focus(); filterTextBox.SelectAll(); } } private void OnTagClosed(object sender, RoutedEventArgs e) { var tag = e.OriginalSource as Tag; var item = tag?.Content; if (item == null) return; if (SelectionMode == SelectionMode.Single) { if (Equals(SelectedItem, item)) SelectedItem = null; } else { SelectedItems?.Remove(item); if (SelectedItems is not INotifyCollectionChanged)//如果SelectedItems不是可通知集合,则需要手动更新ListBox的选择状态 { UpdateListBoxSelection(); } } } private void ListBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (isUpdatingSelection) return; isUpdatingSelection = true; try { if (SelectionMode == SelectionMode.Single) { if (e.AddedItems.Count > 0) { SelectedItem = e.AddedItems[0]; } else if (listBox?.SelectedItem == null) { SelectedItem = null; } SelectedItems.Clear(); if (SelectedItem != null) SelectedItems.Add(SelectedItem); if (IsDropDownOpen) { IsDropDownOpen = false; } } else { SelectedItems ??= new ObservableCollection(); foreach (var item in e.AddedItems) if (!SelectedItems.Contains(item)) SelectedItems.Add(item); foreach (var item in e.RemovedItems) SelectedItems.Remove(item); } } finally { isUpdatingSelection = false; } } private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; c.UpdateFilteredItems(); } private static void OnFilterTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; c.UpdateFilteredItems(); if (!string.IsNullOrEmpty(c.FilterText) && !c.IsDropDownOpen) { c.IsDropDownOpen = true; Debug.WriteLine($"{nameof(OnFilterTextChanged)}:{c.IsDropDownOpen}"); } } /// /// 修改源时,更新ListBox选择状态 /// /// /// private static void OnSelectedItemsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; if (e.OldValue is INotifyCollectionChanged oldCol) oldCol.CollectionChanged -= c.SelectedItems_CollectionChanged; if (e.NewValue is INotifyCollectionChanged newCol) newCol.CollectionChanged += c.SelectedItems_CollectionChanged; c.UpdateListBoxSelection(); } /// /// 前端更新时,刷新源 /// /// /// private void SelectedItems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (isUpdatingSelection) return; isUpdatingSelection = true; try { if (SelectionMode == SelectionMode.Single) { var first = SelectedItems?.Cast().FirstOrDefault(); if (!Equals(SelectedItem, first)) SelectedItem = first; } UpdateListBoxSelection(); } finally { isUpdatingSelection = false; } var added = e.NewItems ?? Array.Empty(); var removed = e.OldItems ?? Array.Empty(); SelectionChanged?.Invoke(this, new SelectionChangedEventArgs(Selector.SelectionChangedEvent, removed, added)); } private static void OnSelectedItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; if (c.isUpdatingSelection) return; if (c.SelectionMode != SelectionMode.Single) return; c.isUpdatingSelection = true; try { if (c.SelectedItems != null) { c.SelectedItems.Clear(); if (c.SelectedItem != null) c.SelectedItems.Add(c.SelectedItem); } c.UpdateListBoxSelection(); var args = new SelectionChangedEventArgs( Selector.SelectionChangedEvent, e.OldValue != null ? [e.OldValue] : Array.Empty(), e.NewValue != null ? [e.NewValue] : Array.Empty()); c.SelectionChanged?.Invoke(c, args); } finally { c.isUpdatingSelection = false; } } private static void OnSelectionModeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var c = (MultiComboBox)d; if (c.listBox != null) c.listBox.SelectionMode = c.SelectionMode; c.isUpdatingSelection = true; try { if (c.SelectionMode == SelectionMode.Single) { var first = c.SelectedItems?.Cast().FirstOrDefault(); c.SelectedItem = first; if (c.SelectedItems != null) { c.SelectedItems.Clear(); if (first != null) c.SelectedItems.Add(first); } } else { if (c.SelectedItem != null && c.SelectedItems != null && !c.SelectedItems.Contains(c.SelectedItem)) c.SelectedItems.Add(c.SelectedItem); } } finally { c.isUpdatingSelection = false; } c.UpdateListBoxSelection(); } private void UpdateListBoxSelection() { if (listBox == null) return; isUpdatingSelection = true; try { if (SelectionMode == SelectionMode.Single) { if (!Equals(listBox.SelectedItem, SelectedItem)) listBox.SelectedItem = SelectedItem; } else { // 优化多选同步算法,避免每次都 ToList() var sourceList = SelectedItems; if (sourceList == null) return; // 使用 HashSet 提高查找速度 O(1) var sourceSet = new HashSet(sourceList.Cast()); var controlList = listBox.SelectedItems; // 1. 移除 ListBox 中有但源中没有的 (倒序遍历以安全删除) for (int i = controlList.Count - 1; i >= 0; i--) { if (!sourceSet.Contains(controlList[i])) { controlList.RemoveAt(i); } } // 2. 添加源中有但 ListBox 中没有的 foreach (var item in sourceList) { if (!controlList.Contains(item)) { controlList.Add(item); } } } } finally { isUpdatingSelection = false; } } //private void UpdateListBoxSelection() //{ // if (listBox == null) // return; // isUpdatingSelection = true; // try // { // switch (SelectionMode) // { // case SelectionMode.Single: // { // // 单选逻辑保持不变 // if (!Equals(listBox.SelectedItem, SelectedItem)) // listBox.SelectedItem = SelectedItem; // break; // } // default: // { // // --- 修改点: 优化多选同步逻辑 --- // var controlSelectedItems = listBox.SelectedItems.Cast().ToList(); // var sourceSelectedItems = SelectedItems?.Cast().ToList() ?? []; // // 1. 找出需要在 ListBox 中移除的项 // var itemsToRemove = controlSelectedItems.Except(sourceSelectedItems).ToList(); // foreach (var item in itemsToRemove) // { // listBox.SelectedItems.Remove(item); // } // // 2. 找出需要在 ListBox 中添加的项 // var itemsToAdd = sourceSelectedItems.Except(controlSelectedItems).ToList(); // foreach (var item in itemsToAdd) // { // listBox.SelectedItems.Add(item); // } // break; // } // } // } // finally // { // isUpdatingSelection = false; // } //} //private void UpdateFilteredItems() //{ // var filtered = new ObservableCollection(); // if (ItemsSource != null) // { // var filter = FilterText?.Trim(); // var displayMember = DisplayMemberPath; // foreach (var item in ItemsSource) // { // if (string.IsNullOrEmpty(filter)) // { // filtered.Add(item); // continue; // } // string? value; // if (!string.IsNullOrEmpty(displayMember)) // { // try // { // var prop = item.GetType().GetProperty(displayMember); // value = prop?.GetValue(item)?.ToString(); // } // catch // { // // 属性不存在或无法访问,忽略 // value = item?.ToString(); // } // } // else // { // value = item?.ToString(); // } // if (string.IsNullOrEmpty(value) || value == null || value.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0) continue; // if (item != null) filtered.Add(item); // } // } // FilteredItems = filtered; //} private void UpdateFilteredItems() { if (ItemsSource == null) { FilteredItems = null; return; } var filter = FilterText?.Trim(); // 如果没有过滤文本,直接返回原列表(或者创建一个视图),避免不必要的循环 if (string.IsNullOrEmpty(filter)) { // 直接转换,如果是 ObservableCollection 不需要复制 FilteredItems = ItemsSource; return; } var filtered = new ObservableCollection(); var displayMember = DisplayMemberPath; // --- 优化点:获取一次 Item 类型和属性描述符,而不是在循环里获取 --- PropertyDescriptor? propDesc = null; var enumerator = ItemsSource.GetEnumerator(); // 检查第一个元素来确定类型(假设集合类型一致) if (enumerator.MoveNext()) { var firstItem = enumerator.Current; if (firstItem != null && !string.IsNullOrEmpty(displayMember)) { propDesc = TypeDescriptor.GetProperties(firstItem)[displayMember]; } // 处理第一个元素 if (IsMatch(firstItem, filter!, propDesc)) filtered.Add(firstItem); } // 处理剩余元素 while (enumerator.MoveNext()) { if (IsMatch(enumerator.Current, filter, propDesc)) filtered.Add(enumerator.Current); } FilteredItems = filtered; } // 辅助方法,内联以减少调用开销 private static bool IsMatch(object? item, string filter, PropertyDescriptor? propDesc) { if (item == null) return false; string? value; try { // 相比 GetProperty,PropertyDescriptor 通常更快且支持更多类型 value = propDesc != null ? propDesc.GetValue(item)?.ToString() : item.ToString(); } catch { value = item.ToString(); } return value != null && value.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; } }