【问题标题】:ComboBox with both filter and auto-complete具有过滤器和自动完成功能的组合框
【发布时间】:2019-11-23 13:36:46
【问题描述】:

有没有人成功使用 WPF 的 ComboBox 自动完成和过滤功能?我现在已经花了几个小时,但无法确定它。这是 WPF + MVVM Light。这是我的设置。

虚拟机层

提供以下属性的 ViewModel:

  • FilterText(string):用户在文本框区域输入的文本进行过滤。在FilteredItems 上触发更改通知。
  • Items(List<string>):这是包含所有选项的主要数据源。
  • FilteredItems:使用FilterText 过滤的Items 列表。
  • SelectedOption (string):当前选择的选项。

视图层

一个组合框,用户只能从下拉选项中进行选择。但是,应该允许用户在文本框区域中键入文本,并且下拉列表应该过滤掉不以键入的文本开头的项目。第一个匹配项应自动附加到文本框(即自动完成)。这是我的绑定:

  • ItemsSource:绑定到FilteredItems,单向
  • Text 绑定到 FilterText,双向
  • SelectedItem 绑定到 SelectedOption,双向

IsTextSearchEnabled 设置为 true 以启用自动完成。

此设置的问题是,一旦用户键入第一个字母,就会触发自动完成并尝试找到第一个匹配条目,如果找到,则将 SelectedItem 设置为该条目,其中设置 Text ComboBox 的属性到该项目,这反过来会触发过滤操作,下拉列表中只剩下一个与 Text 完全匹配的条目,这不是应该的样子。

例如,如果用户键入“C”,自动完成将尝试查找以“C”开头的第一个条目。假设第一个匹配条目是“客户”。自动完成将选择该条目,这会将SelectedItem 设置为“客户”,因此Text 也将成为“客户”。由于绑定,这将调用FilterText,这将更新FilteredItems,现在将返回只有一个条目,而不是返回所有以“C”开头的条目。

我在这里错过了什么?

【问题讨论】:

    标签: c# wpf mvvm combobox autocomplete


    【解决方案1】:

    我认为你的方法太复杂了。
    您可以实现一个简单的附加行为,以在启用自动完成时实现过滤建议列表。

    除了ComboBox.ItemsSource 的公共源集合之外,此示例不需要任何其他属性。过滤是通过使用ICollectionView.Filter 属性完成的。这将仅修改ItemsControl 的内部源集合的视图,而不是底层绑定源集合本身。启用自动完成不需要将IsTextSearchEnabled 设置为True

    基本思想是在TextBox.TextChanged 而不是ComboBox.SelectedItemChanged(或一般ComboBox.SelectedItem)上触发过滤。

    ComboBox.cs

    class ComboBox : DependencyObject
    {
      #region IsFilterOnAutoCompleteEnabled attached property
    
      public static readonly DependencyProperty IsFilterOnAutocompleteEnabledProperty =
        DependencyProperty.RegisterAttached(
          "IsFilterOnAutocompleteEnabled",
          typeof(bool),
          typeof(ComboBox),
          new PropertyMetadata(default(bool), ComboBox.OnIsFilterOnAutocompleteEnabledChanged));
    
      public static void SetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement, bool value) =>
        attachingElement.SetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty, value);
    
      public static bool GetIsFilterOnAutocompleteEnabled(DependencyObject attachingElement) =>
        (bool)attachingElement.GetValue(ComboBox.IsFilterOnAutocompleteEnabledProperty);
    
      #endregion
    
      // Use hash tables for faster lookup
      private static Dictionary<TextBox, System.Windows.Controls.ComboBox> TextBoxComboBoxMap { get; }
      private static Dictionary<TextBox, int> TextBoxSelectionStartMap { get; }
      private static Dictionary<System.Windows.Controls.ComboBox, TextBox> ComboBoxTextBoxMap { get; }
      private static bool IsNavigationKeyPressed { get; set; }
    
      static ComboBox()
      {
        ComboBox.TextBoxComboBoxMap = new Dictionary<TextBox, System.Windows.Controls.ComboBox>();
        ComboBox.TextBoxSelectionStartMap = new Dictionary<TextBox, int>();
        ComboBox.ComboBoxTextBoxMap = new Dictionary<System.Windows.Controls.ComboBox, TextBox>();
      }
    
      private static void OnIsFilterOnAutocompleteEnabledChanged(
        DependencyObject attachingElement,
        DependencyPropertyChangedEventArgs e)
      {
        if (!(attachingElement is System.Windows.Controls.ComboBox comboBox
          && comboBox.IsEditable))
        {
          return;
        }
    
        if (!(bool)e.NewValue)
        {
          ComboBox.DisableAutocompleteFilter(comboBox);
          return;
        }
    
        if (!comboBox.IsLoaded)
        {
          comboBox.Loaded += ComboBox.EnableAutocompleteFilterOnComboBoxLoaded;
          return;
        }
        ComboBox.EnableAutocompleteFilter(comboBox);
      }
    
      private static async void FilterOnTextInput(object sender, TextChangedEventArgs e)
      {
        await Application.Current.Dispatcher.InvokeAsync(
          () =>
          {
            if (ComboBox.IsNavigationKeyPressed)
            {
              return;
            }
    
            var textBox = sender as TextBox;
            int textBoxSelectionStart = textBox.SelectionStart;
            ComboBox.TextBoxSelectionStartMap[textBox] = textBoxSelectionStart;
    
            string changedTextOnAutocomplete = textBox.Text.Substring(0, textBoxSelectionStart);
            if (ComboBox.TextBoxComboBoxMap.TryGetValue(
              textBox,
              out System.Windows.Controls.ComboBox comboBox))
            {
              comboBox.Items.Filter = item => item.ToString().StartsWith(
                changedTextOnAutocomplete,
                StringComparison.OrdinalIgnoreCase);
            }
          },
          DispatcherPriority.Background);
      }
    
      private static async void HandleKeyDownWhileFiltering(object sender, KeyEventArgs e)
      {
        var comboBox = sender as System.Windows.Controls.ComboBox;
        if (!ComboBox.ComboBoxTextBoxMap.TryGetValue(comboBox, out TextBox textBox))
        {
          return;
        }
    
        switch (e.Key)
        {
          case Key.Down 
            when comboBox.Items.CurrentPosition < comboBox.Items.Count - 1 
                 && comboBox.Items.MoveCurrentToNext():
          case Key.Up 
            when comboBox.Items.CurrentPosition > 0 
                 && comboBox.Items.MoveCurrentToPrevious():
          {
            // Prevent the filter from re-apply as this would override the
            // current selection start index
            ComboBox.IsNavigationKeyPressed = true;
    
            // Ensure the Dispatcher en-queued delegate 
            // (and the invocation of the SelectCurrentItem() method)
            // executes AFTER the FilterOnTextInput() event handler.
            // This is because key input events have a higher priority
            // than text change events by default. The goal is to make the filtering 
            // triggered by the TextBox.TextChanged event ignore the changes 
            // introduced by this KeyDown event.
            // DispatcherPriority.ContextIdle will force to "override" this behavior.
            await Application.Current.Dispatcher.InvokeAsync(
              () =>
              {
                ComboBox.SelectCurrentItem(textBox, comboBox);
                ComboBox.IsNavigationKeyPressed = false;
              }, 
              DispatcherPriority.ContextIdle);
    
            break;
          }
        }
      }
    
      private static void SelectCurrentItem(TextBox textBox, System.Windows.Controls.ComboBox comboBox)
      {
        comboBox.SelectedItem = comboBox.Items.CurrentItem;
        if (ComboBox.TextBoxSelectionStartMap.TryGetValue(textBox, out int selectionStart))
        {
          textBox.SelectionStart = selectionStart;
        }
      }
    
      private static void EnableAutocompleteFilterOnComboBoxLoaded(object sender, RoutedEventArgs e)
      {
        var comboBox = sender as System.Windows.Controls.ComboBox;
        ComboBox.EnableAutocompleteFilter(comboBox);
      }
    
      private static void EnableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
      {
        if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
        {
          ComboBox.TextBoxComboBoxMap.Add(editTextBox, comboBox);
          ComboBox.ComboBoxTextBoxMap.Add(comboBox, editTextBox);
          editTextBox.TextChanged += ComboBox.FilterOnTextInput;
    
          // Need to receive handled KeyDown event
          comboBox.AddHandler(UIElement.PreviewKeyDownEvent, new KeyEventHandler(HandleKeyDownWhileFiltering), true);
        }
      }
    
      private static void DisableAutocompleteFilter(System.Windows.Controls.ComboBox comboBox)
      {
        if (comboBox.TryFindVisualChildElement(out TextBox editTextBox))
        {
          ComboBox.TextBoxComboBoxMap.Remove(editTextBox);
          editTextBox.TextChanged -= ComboBox.FilterOnTextInput;
        }
      }
    }
    

    Extensions.cs

    public static class Extensions
    { 
      /// <summary>
      /// Traverses the visual tree towards the leafs until an element with a matching element type is found.
      /// </summary>
      /// <typeparam name="TChild">The type the visual child must match.</typeparam>
      /// <param name="parent"></param>
      /// <param name="resultElement"></param>
      /// <returns></returns>
      public static bool TryFindVisualChildElement<TChild>(this DependencyObject parent, out TChild resultElement)
        where TChild : DependencyObject
      {
        resultElement = null;
    
        if (parent is Popup popup)
        {
          parent = popup.Child;
          if (parent == null)
          {
            return false;
          }
        }
    
        for (var childIndex = 0; childIndex < VisualTreeHelper.GetChildrenCount(parent); childIndex++)
        {
          DependencyObject childElement = VisualTreeHelper.GetChild(parent, childIndex);
          if (childElement is TChild child)
          {
            resultElement = child;
            return true;
          }
    
          if (childElement.TryFindVisualChildElement(out resultElement))
          {
            return true;
          }
        }
    
        return false;
      }
    }
    

    使用示例

    <ComboBox ItemsSource="{Binding Items}" 
              IsEditable="True"
              ComboBox.IsFilterOnAutocompleteEnabled="True" />
    

    【讨论】:

    • 谢谢。 TryFindVisualChildElement 是什么?
    • 遍历可视化树的助手。我已添加它以完成示例
    • 我很困惑。这是行为还是控制?如果控制,为什么不继承自内置的ComboBox
    • 它基本上是一个附加属性。在任何ComboBox 上将其设置为启用时,它将跟踪编辑TextBlock 的文本更改,这是ComboBox 模板的一部分。请参阅使用示例
    • 如果您更喜欢扩展ComboBox,您可以这样做并使用完全相同的代码,从从Loaded 事件处理程序调用EnableAutocompleteFilter 开始。这将达到相同的结果。
    猜你喜欢
    • 1970-01-01
    • 2011-04-19
    • 1970-01-01
    • 2012-05-03
    • 1970-01-01
    • 1970-01-01
    • 2013-11-25
    • 2019-03-08
    • 2012-08-31
    相关资源
    最近更新 更多