windowsgm
windowsgm

Reputation: 1616

ComboBox Filter Backspace Not Working as Expected

I'm implementing a filterable Combobox but have been running into trouble. My implementation below works initially for typing to filter results but once you select an item from the dropdown it becomes difficult to backspace characters or delete all for the Textbox filter. It appears like the Combobox auto-selects the originally selected value and resets to that on each button press. Below is the minimum code to reproduce the issue which will demonstrate the issue clearer. Simply select a value from the dropdown and then backspace or delete text to try to select a new value.

With the following test data;

public IEnumerable<Fruit> Fruits { get; set; }
    public MainWindow()
    {
        List<Fruit> temp = new List<Fruit>
        {
            new Fruit(Name = "apple"),
            new Fruit(Name = "banana"),
            new Fruit(Name = "grape"),
            new Fruit(Name = "lemon"),
            new Fruit(Name = "strawberry")
        };
        Fruits = temp;
        DataContext = this;
        InitializeComponent();
    }
}

public class Fruit
{
    public string Name { get; set; }

    public Fruit(string name)
    {
        Name = name;
    }
}

Updated FilteredComboBox code;

public class FilteredComboBox : ComboBox
{
    public static readonly DependencyProperty MinimumSearchLengthProperty = DependencyProperty.Register(
      "MinimumSearchLength",
      typeof(int),
      typeof(FilteredComboBox),
      new UIPropertyMetadata(3));

    public int MinimumSearchLength
    {
        get => (int)this.GetValue(FilteredComboBox.MinimumSearchLengthProperty);
        set => SetValue(FilteredComboBox.MinimumSearchLengthProperty, value);
    }

    public TextBox PART_EditableTextBox { get; set; }

    static FilteredComboBox()
    {
        // Register a delegate to handle property changes of the Text dependency property
        ComboBox.TextProperty.OverrideMetadata(
          typeof(FilteredComboBox),
          new FrameworkPropertyMetadata(default, FilteredComboBox.FilterItemsOnTextChanged));
    }

    public FilteredComboBox()
    {
        this.IsTextSearchEnabled = false;
    }

    private static void FilterItemsOnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var this_ = d as FilteredComboBox;
        this_.IsDropDownOpen = true;
        this_.Items.Filter =
          item => e.NewValue.ToString().Length < this_.MinimumSearchLength
            || ((Fruit)item).Name.Contains(e.NewValue.ToString());
    }

    /// <inheritdoc />
    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        this.PART_EditableTextBox = GetTemplateChild("PART_EditableTextBox") as TextBox;
        this.PART_EditableTextBox.TextChanged += RemoveTextSelectionOnTextBoxTextChanged;
    }

    /// <inheritdoc />
    protected override void OnKeyUp(KeyEventArgs e)
    {
        base.OnKeyUp(e);

        switch (e.Key)
        {
            // Clear the filter and reset the text field
            case Key.Tab:
            case Key.Enter:
                this.Items.Filter = null;
                this.PART_EditableTextBox.Text = string.Empty;
                break;
            case Key.Down:
                this.IsDropDownOpen = true;
                break;
        }
    }

    private void RemoveTextSelectionOnTextBoxTextChanged(object sender, TextChangedEventArgs e)
    {
        if (this.PART_EditableTextBox.SelectionLength > 0)
        {
            this.PART_EditableTextBox.CaretIndex = e.Changes.FirstOrDefault()?.Offset + 1
                                                   ?? this.PART_EditableTextBox.Text.Length;
        }
    }
}

And am implementing it in a test solution like so;

<local:FilteredComboBox
        MinimumSearchLength="2"
        IsEditable="True"
        ItemsSource="{Binding Fruits}"
        DisplayMemberPath = "Name">
    </local:FilteredComboBox>

Upvotes: 2

Views: 724

Answers (1)

BionicCode
BionicCode

Reputation: 29028

You can't edit the text of the edit TextBox as long there is a SelectedItem value assigned. The ComboBox internally tracks the changes of the edit text box to synchronize it with a matching item, when the control ComboBox.IsEditable is set to true. Changes that occurred during the ComboBox.Text change process are reverted/aborted. Deleting the current SelectedItem reference may help, but in most cases this isn't desirable.
It seems reasonable to execute the filtering before or after the internal lookup process. For this purpose I recommend to register a PropertyChangedCallback with the ComboBox.TextProperty. The edit text box is synchronized with the ComboBox.Text property, where the ComboBox.Text changes first to trigger the internal ComboBox selected item matching process.

I think there is some optimization potential:

  • You shouldn't call FrameworkElement.GetTemplateChild in the get method of the EditableTextBox property (calculated property). This results in a template lookup for every property read access, although the returned element will never change. You should call FrameworkElement.GetTemplateChild once in the FrameworkElement.OnApplyTemplate override and store the instance in a private property.
  • You shouldn't operate on the ItemsControl.ItemsSource, but on the ItemsControl.Items property instead. ItemsControl.Items returns a ItemCollection, which already implements a ICollectionView. Use the ItemsControl.Items property to sort, group or filter the items (without affecting the original source collection).
  • Instead of executing the filtering on key presses, you should listen to the text changes (as suggested before).
  • Some key events are redundant: Esc and Enter will by default close the dropdown

The fixed and refactored FilteredComboBox control could look as followed:

public class FilteredComboBox : ComboBox
{
  #region Dependency properties

  public static readonly DependencyProperty MinimumSearchLengthProperty =
    DependencyProperty.Register(
      "MinimumSearchLength",
      typeof(int),
      typeof(FilteredComboBox),
      new UIPropertyMetadata(3));

  [Description("Length of the search string that triggers filtering.")]
  [Category("Filtered ComboBox")]
  [DefaultValue(1)]
  public int MinimumSearchLength
  {
    [DebuggerStepThrough]
    get => (int)GetValue(FilteredComboBox.MinimumSearchLengthProperty);

    [DebuggerStepThrough]
    set => SetValue(FilteredComboBox.MinimumSearchLengthProperty, value);
  }

  private static object CoerceValue(DependencyObject d, object baseValue)
  {
    var this_ = d as FilteredComboBox;
    this_.CaretIndexBeforeDropDownOpen = this_.PART_EditableTextBox.CaretIndex;
    return baseValue;
  }

  #endregion

  static FilteredComboBox()
  {
    ComboBox.TextProperty.OverrideMetadata(
      typeof(FilteredComboBox),
      new FrameworkPropertyMetadata(default, FilteredComboBox.OnTextChanged));
    ComboBox.IsDropDownOpenProperty.OverrideMetadata(
      typeof(FilteredComboBox),
      new FrameworkPropertyMetadata(false, null, FilteredComboBox.CoerceValue));
  }

  public FilteredComboBox()
  {
    this.IsTextSearchEnabled = false;
    this.DropDownOpened += RemoveTextSelectionOnDropDownOpened;
  }

  private static void OnTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
  {
    var this_ = d as FilteredComboBox;
    this_.IsDropDownOpen = true;
    if (FilteredComboBox.IsNavigating)
    {
      FilteredComboBox.IsNavigating = false;
      return;
    }

    this_.Items.Filter = item => this_.PART_EditableTextBox.Text.Length < this_.MinimumSearchLength
                                 || ((Fruit) item).Name.Contains(this_.PART_EditableTextBox.Text);

    if (string.IsNullOrEmpty(this_.PART_EditableTextBox.Text))
    {
      this_.ForceTextSiteSync();
    }
  }

  private void ForceTextSiteSync()
  {
    Application.Current.Dispatcher.InvokeAsync(
      () =>
      {
        object currentItem = this.SelectedItem;
        this.SelectedItem = null;
        this.SelectedItem = currentItem;
        this.Items.Filter = null;
      },
      DispatcherPriority.ContextIdle);
  }

  private void RemoveTextSelectionOnDropDownOpened(object sender, EventArgs e)
  {
    if (this.IsSelectionBoxHighlighted)
    {
      this.PART_EditableTextBox.CaretIndex = this.CaretIndexBeforeDropDownOpen;
    }
  }

  /// <inheritdoc />
  protected override void OnSelectionChanged(SelectionChangedEventArgs e)
  {
    base.OnSelectionChanged(e);
    FilteredComboBox.IsNavigating = true;
  }

  /// <inheritdoc />
  public override void OnApplyTemplate()
  {
    base.OnApplyTemplate();
    this.PART_EditableTextBox = GetTemplateChild("PART_EditableTextBox") as TextBox;
  }

  /// <inheritdoc />
  protected override void OnPreviewKeyUp(KeyEventArgs e)
  {
    base.OnPreviewKeyUp(e);
    this.IsDropDownOpen = true;

    switch (e.Key)
    {
      case Key.Back when string.IsNullOrEmpty(this.PART_EditableTextBox.Text):
      case Key.Delete when string.IsNullOrEmpty(this.PART_EditableTextBox.Text):
        ForceTextSiteSync();
        break;
      case Key.Tab:
      case Key.Enter:
        this.Items.Filter = null;
        ForceTextSiteSync();
        break;
    }
  }

  private TextBox PART_EditableTextBox { get; set; }
  private int CaretIndexBeforeDropDownOpen { get; set; }
  private static bool IsNavigating { get; set; }
}

Upvotes: 1

Related Questions