diff --git a/samples/ControlCatalog/Pages/ComboBoxPage.xaml b/samples/ControlCatalog/Pages/ComboBoxPage.xaml index ab347ac30c..bed7d2fe48 100644 --- a/samples/ControlCatalog/Pages/ComboBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ComboBoxPage.xaml @@ -102,9 +102,8 @@ - - + DisplayMemberBinding="{Binding Name}" + TextSearch.TextBinding="{Binding SearchText, DataType=viewModels:IdAndName}" /> Values { get; set; } = new ObservableCollection { - new IdAndName(){ Id = "Id 1", Name = "Name 1" }, - new IdAndName(){ Id = "Id 2", Name = "Name 2" }, - new IdAndName(){ Id = "Id 3", Name = "Name 3" }, - new IdAndName(){ Id = "Id 4", Name = "Name 4" }, - new IdAndName(){ Id = "Id 5", Name = "Name 5" }, + new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" }, + new IdAndName(){ Id = "Id 2", Name = "Name 2", SearchText = "B" }, + new IdAndName(){ Id = "Id 3", Name = "Name 3", SearchText = "C" }, + new IdAndName(){ Id = "Id 4", Name = "Name 4", SearchText = "D" }, + new IdAndName(){ Id = "Id 5", Name = "Name 5", SearchText = "E" }, }; } @@ -31,5 +31,6 @@ namespace ControlCatalog.ViewModels { public string? Id { get; set; } public string? Name { get; set; } + public string? SearchText { get; set; } } } diff --git a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs index b142ce5eb2..4241f6315f 100644 --- a/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs @@ -2036,6 +2036,7 @@ namespace Avalonia.Controls } } + // TODO12: Remove, this shouldn't be part of the public API. Use our internal BindingEvaluator instead. /// /// A framework element that permits a binding to be evaluated in a new data /// context leaf node. diff --git a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs index 58d0e797fe..3b9f0b08d3 100644 --- a/src/Avalonia.Controls/Presenters/ItemsPresenter.cs +++ b/src/Avalonia.Controls/Presenters/ItemsPresenter.cs @@ -197,44 +197,6 @@ namespace Avalonia.Controls.Presenters return Panel?.Children; } - internal static bool ControlMatchesTextSearch(Control control, string textSearchTerm) - { - if (control is AvaloniaObject ao && ao.IsSet(TextSearch.TextProperty)) - { - var searchText = ao.GetValue(TextSearch.TextProperty); - - if (searchText?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true) - { - return true; - } - } - return control is IContentControl cc && - cc.Content?.ToString()?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true; - } - - internal int GetIndexFromTextSearch(string textSearch) - { - if (Panel is VirtualizingPanel v) - return v.GetIndexFromTextSearch(textSearch); - return GetIndexFromTextSearch(ItemsControl?.Items, textSearch); - } - - internal static int GetIndexFromTextSearch(IReadOnlyList? items, string textSearchTerm) - { - if (items is null) - return -1; - - for (var i = 0; i < items.Count; i++) - { - if (items[i] is Control c && ControlMatchesTextSearch(c, textSearchTerm) - || items[i]?.ToString()?.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase) == true) - { - return i; - } - } - return -1; - } - internal int IndexFromContainer(Control container) { if (Panel is VirtualizingPanel v) diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index f830131916..0d98d41f08 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -5,6 +5,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using Avalonia.Controls.Selection; +using Avalonia.Controls.Utils; using Avalonia.Data; using Avalonia.Input; using Avalonia.Interactivity; @@ -146,7 +147,7 @@ namespace Avalonia.Controls.Primitives private bool _ignoreContainerSelectionChanged; private UpdateState? _updateState; private bool _hasScrolledToSelectedItem; - private BindingHelper? _bindingHelper; + private BindingEvaluator? _selectedValueBindingEvaluator; private bool _isSelectionChangeActive; public SelectingItemsControl() @@ -609,10 +610,10 @@ namespace Avalonia.Controls.Primitives _textSearchTerm += e.Text; - var newIndex = Presenter?.GetIndexFromTextSearch(_textSearchTerm); + var newIndex = GetIndexFromTextSearch(_textSearchTerm); if (newIndex >= 0) { - SelectedIndex = (int)newIndex; + SelectedIndex = newIndex; } StartTextSearchTimer(); @@ -678,17 +679,10 @@ namespace Avalonia.Controls.Primitives { _isSelectionChangeActive = true; - if (_bindingHelper is null) - { - _bindingHelper = new BindingHelper(value); - } - else - { - _bindingHelper.UpdateBinding(value); - } + var bindingEvaluator = GetSelectedValueBindingEvaluator(value); // Re-evaluate SelectedValue with the new binding - SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(selectedItem)); + SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(selectedItem)); } finally { @@ -1067,20 +1061,23 @@ namespace Avalonia.Controls.Primitives } } - _bindingHelper ??= new BindingHelper(binding); + var bindingEvaluator = GetSelectedValueBindingEvaluator(binding); // Matching UWP behavior, if duplicates are present, return the first item matching // the SelectedValue provided foreach (var item in items!) { - var itemValue = _bindingHelper.Evaluate(item); + var itemValue = bindingEvaluator.Evaluate(item); if (Equals(itemValue, value)) { + bindingEvaluator.ClearDataContext(); return item; } } + bindingEvaluator.ClearDataContext(); + return AvaloniaProperty.UnsetValue; } @@ -1107,12 +1104,12 @@ namespace Avalonia.Controls.Primitives return; } - _bindingHelper ??= new BindingHelper(binding); + var bindingEvaluator = GetSelectedValueBindingEvaluator(binding); try { _isSelectionChangeActive = true; - SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(item)); + SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(item)); } finally { @@ -1338,6 +1335,37 @@ namespace Avalonia.Controls.Primitives StopTextSearchTimer(); } + private int GetIndexFromTextSearch(string textSearchTerm) + { + if (string.IsNullOrEmpty(textSearchTerm)) + return -1; + + var count = Items.Count; + if (count == 0) + return -1; + + var textBinding = TextSearch.GetTextBinding(this) ?? DisplayMemberBinding; + using var textBindingEvaluator = BindingEvaluator.TryCreate(textBinding); + + for (var i = 0; i < count; i++) + { + var text = TextSearch.GetEffectiveText(Items[i], textBindingEvaluator); + if (text.StartsWith(textSearchTerm, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + + return -1; + } + + private BindingEvaluator GetSelectedValueBindingEvaluator(IBinding binding) + { + _selectedValueBindingEvaluator ??= new(); + _selectedValueBindingEvaluator.UpdateBinding(binding); + return _selectedValueBindingEvaluator; + } + // When in a BeginInit..EndInit block, or when the DataContext is updating, we need to // defer changes to the selection model because we have no idea in which order properties // will be set. Consider: @@ -1367,41 +1395,5 @@ namespace Avalonia.Controls.Primitives public Optional SelectedItem { get; set; } public Optional SelectedValue { get; set; } } - - /// - /// Helper class for evaluating a binding from an Item and IBinding instance - /// - private class BindingHelper : StyledElement - { - private BindingExpressionBase? _expression; - private IBinding? _lastBinding; - - public BindingHelper(IBinding binding) - { - UpdateBinding(binding); - } - - public static readonly StyledProperty ValueProperty = - AvaloniaProperty.Register("Value"); - - public object? Evaluate(object? dataContext) - { - // Only update the DataContext if necessary - if (!Equals(dataContext, DataContext)) - DataContext = dataContext; - - return GetValue(ValueProperty); - } - - public void UpdateBinding(IBinding binding) - { - if (binding == _lastBinding) - return; - - _expression?.Dispose(); - _expression = Bind(ValueProperty, binding); - _lastBinding = binding; - } - } } } diff --git a/src/Avalonia.Controls/Primitives/TextSearch.cs b/src/Avalonia.Controls/Primitives/TextSearch.cs index 962fba361e..d7dd4d9341 100644 --- a/src/Avalonia.Controls/Primitives/TextSearch.cs +++ b/src/Avalonia.Controls/Primitives/TextSearch.cs @@ -1,3 +1,5 @@ +using Avalonia.Controls.Utils; +using Avalonia.Data; using Avalonia.Interactivity; namespace Avalonia.Controls.Primitives @@ -9,29 +11,95 @@ namespace Avalonia.Controls.Primitives { /// /// Defines the Text attached property. - /// This text will be considered during text search in (such as ) + /// This text will be considered during text search in (such as ). + /// This property is usually applied to an item container directly. /// public static readonly AttachedProperty TextProperty = AvaloniaProperty.RegisterAttached("Text", typeof(TextSearch)); /// - /// Sets the for a control. + /// Defines the TextBinding attached property. + /// The binding will be applied to each item during text search in (such as ). /// - /// The control - /// The search text to set + public static readonly AttachedProperty TextBindingProperty + = AvaloniaProperty.RegisterAttached("TextBinding", typeof(TextSearch)); + + // TODO12: Control should be Interactive to match the property definition. + /// + /// Sets the value of the attached property to a given . + /// + /// The control. + /// The search text to set. public static void SetText(Control control, string? text) - { - control.SetValue(TextProperty, text); - } + => control.SetValue(TextProperty, text); + // TODO12: Control should be Interactive to match the property definition. /// - /// Gets the of a control. + /// Gets the value of the attached property from a given . /// - /// The control - /// The property value + /// The control. + /// The search text. public static string? GetText(Control control) + => control.GetValue(TextProperty); + + /// + /// Sets the value of the attached property to a given . + /// + /// The interactive element. + /// The search text binding to set. + public static void SetTextBinding(Interactive interactive, IBinding? value) + => interactive.SetValue(TextBindingProperty, value); + + /// + /// Gets the value of the attached property from a given . + /// + /// The interactive element. + /// The search text binding. + [AssignBinding] + public static IBinding? GetTextBinding(Interactive interactive) + => interactive.GetValue(TextBindingProperty); + + /// + /// Gets the effective text of a given item. + /// + /// This method uses the first non-empty text from the following list: + /// + /// (if the item is a control) + /// + /// + /// . (if the item is a ) + /// + /// + /// + /// + /// The item. + /// A used to get the item's text from a binding. + /// The item's text. + internal static string GetEffectiveText(object? item, BindingEvaluator? textBindingEvaluator) { - return control.GetValue(TextProperty); + if (item is null) + return string.Empty; + + string? text; + + if (item is Interactive interactive) + { + text = interactive.GetValue(TextProperty); + if (!string.IsNullOrEmpty(text)) + return text; + } + + if (textBindingEvaluator is not null) + { + text = textBindingEvaluator.Evaluate(item); + if (!string.IsNullOrEmpty(text)) + return text; + } + + if (item is IContentControl contentControl) + return contentControl.Content?.ToString() ?? string.Empty; + + return item.ToString() ?? string.Empty; } } } diff --git a/src/Avalonia.Controls/Utils/BindingEvaluator.cs b/src/Avalonia.Controls/Utils/BindingEvaluator.cs new file mode 100644 index 0000000000..c67799eae5 --- /dev/null +++ b/src/Avalonia.Controls/Utils/BindingEvaluator.cs @@ -0,0 +1,61 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Data; + +namespace Avalonia.Controls.Utils; + +/// +/// Helper class for evaluating a binding from an Item and IBinding instance +/// +internal sealed class BindingEvaluator : StyledElement, IDisposable +{ + private BindingExpressionBase? _expression; + private IBinding? _lastBinding; + + [SuppressMessage( + "AvaloniaProperty", + "AVP1002:AvaloniaProperty objects should not be owned by a generic type", + Justification = "This property is not supposed to be used from XAML.")] + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register, T>("Value"); + + public T Evaluate(object? dataContext) + { + // Only update the DataContext if necessary + if (!Equals(dataContext, DataContext)) + DataContext = dataContext; + + return GetValue(ValueProperty); + } + + public void UpdateBinding(IBinding binding) + { + if (binding == _lastBinding) + return; + + _expression?.Dispose(); + _expression = Bind(ValueProperty, binding); + _lastBinding = binding; + } + + public void ClearDataContext() + => DataContext = this; + + public void Dispose() + { + _expression?.Dispose(); + _expression = null; + _lastBinding = null; + DataContext = null; + } + + public static BindingEvaluator? TryCreate(IBinding? binding) + { + if (binding is null) + return null; + + var evaluator = new BindingEvaluator(); + evaluator.UpdateBinding(binding); + return evaluator; + } +} diff --git a/src/Avalonia.Controls/VirtualizingPanel.cs b/src/Avalonia.Controls/VirtualizingPanel.cs index c2ae8445df..13fa61e601 100644 --- a/src/Avalonia.Controls/VirtualizingPanel.cs +++ b/src/Avalonia.Controls/VirtualizingPanel.cs @@ -193,11 +193,6 @@ namespace Avalonia.Controls Children.RemoveRange(index, count); } - internal int GetIndexFromTextSearch(string textSearchTerm) - { - return ItemsPresenter.GetIndexFromTextSearch(Items, textSearchTerm); - } - private protected override void InvalidateMeasureOnChildrenChanged() { // Don't invalidate measure when children are added or removed: the panel is responsible diff --git a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs index 00a31fafe7..3033decca9 100644 --- a/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Linq; using System.Reactive.Subjects; using Avalonia.Controls.Presenters; @@ -253,25 +255,105 @@ namespace Avalonia.Controls.UnitTests int initialSelectedIndex, int expectedSelectedIndex, string searchTerm, - params string[] items) + params string[] contents) + { + TestTextSearch( + initialSelectedIndex, + expectedSelectedIndex, + searchTerm, + _ => { }, + contents.Select(content => new ComboBoxItem { Content = content })); + } + + [Theory] + [InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })] + [InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })] + public void TextSearch_With_TextSearchText_Should_Have_Expected_SelectedIndex( + int initialSelectedIndex, + int expectedSelectedIndex, + string searchTerm, + string[] contents, + string[] searchTexts) + { + Assert.Equal(contents.Length, searchTexts.Length); + + TestTextSearch( + initialSelectedIndex, + expectedSelectedIndex, + searchTerm, + _ => { }, + contents.Select((item, index) => + { + var comboBoxItem = new ComboBoxItem { Content = item }; + TextSearch.SetText(comboBoxItem, searchTexts[index]); + return comboBoxItem; + })); + } + + [Theory] + [InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })] + [InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })] + public void TextSearch_With_DisplayMemberBinding_Should_Have_Expected_SelectedIndex( + int initialSelectedIndex, + int expectedSelectedIndex, + string searchTerm, + string[] values, + string[] displays) + { + Assert.Equal(values.Length, displays.Length); + + TestTextSearch( + initialSelectedIndex, + expectedSelectedIndex, + searchTerm, + comboBox => comboBox.DisplayMemberBinding = new Binding(nameof(Item.Display)), + values.Select((value, index) => new Item(value, displays[index]))); + } + + [Theory] + [InlineData(-1, 1, "c", new[] { "A item", "B item", "C item" }, new[] { "B search", "C search", "A search" })] + [InlineData(0, 2, "baz", new[] { "A item", "B item", "C item" }, new[] { "foo", "bar", "baz" })] + public void TextSearch_With_TextSearchBinding_Should_Have_Expected_SelectedIndex( + int initialSelectedIndex, + int expectedSelectedIndex, + string searchTerm, + string[] values, + string[] displays) + { + Assert.Equal(values.Length, displays.Length); + + TestTextSearch( + initialSelectedIndex, + expectedSelectedIndex, + searchTerm, + comboBox => TextSearch.SetTextBinding(comboBox, new Binding(nameof(Item.Display))), + values.Select((value, index) => new Item(value, displays[index]))); + } + + private static void TestTextSearch( + int initialSelectedIndex, + int expectedSelectedIndex, + string searchTerm, + Action configureComboBox, + IEnumerable itemsSource) { using (UnitTestApplication.Start(TestServices.StyledWindow)) { var target = new ComboBox { - Template = GetTemplate(), - ItemsSource = items.Select(x => new ComboBoxItem { Content = x }), + Template = GetTemplate(), + ItemsSource = itemsSource.ToArray(), }; + configureComboBox(target); + TestRoot root = new(target) { - ClientSize = new(500,500), + ClientSize = new(500,500) }; - - target.ApplyTemplate(); - target.Presenter.ApplyTemplate(); - target.SelectedIndex = initialSelectedIndex; + root.LayoutManager.ExecuteInitialLayoutPass(); + target.SelectedIndex = initialSelectedIndex; var args = new TextInputEventArgs { @@ -284,7 +366,7 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(expectedSelectedIndex, target.SelectedIndex); } } - + [Fact] public void SelectedItem_Validation() { @@ -551,5 +633,7 @@ namespace Avalonia.Controls.UnitTests target.SelectedItem = null; Assert.Null(target.SelectionBoxItem); } + + private sealed record Item(string Value, string Display); } }