Browse Source

Implement TextSearch.TextBinding (#18405)

* Implement TextSearch.TextBinding

* Move AssignBinding to TextSearch.GetTextBinding
Julien Lebosquain 7 months ago
parent
commit
b09e0d5677

+ 2 - 3
samples/ControlCatalog/Pages/ComboBoxPage.xaml

@@ -102,9 +102,8 @@
                 <ComboBox 
                     WrapSelection="{Binding WrapSelection}" 
                     ItemsSource="{Binding Values}" 
-                    DisplayMemberBinding="{Binding Name}">
-                
-                </ComboBox>
+                    DisplayMemberBinding="{Binding Name}"
+                    TextSearch.TextBinding="{Binding SearchText, DataType=viewModels:IdAndName}" />
               
                 <ComboBox 
                     WrapSelection="{Binding WrapSelection}" 

+ 6 - 5
samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs

@@ -19,11 +19,11 @@ namespace ControlCatalog.ViewModels
 
         public ObservableCollection<IdAndName> Values { get; set; } = new ObservableCollection<IdAndName>
         {
-            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; }
     }
 }

+ 1 - 0
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.
         /// <summary>
         /// A framework element that permits a binding to be evaluated in a new data
         /// context leaf node.

+ 0 - 38
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<object?>? 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)

+ 44 - 52
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<object?>? _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<string?>.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<object?> 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<object?> SelectedItem { get; set; }
             public Optional<object?> SelectedValue { get; set; }
         }
-
-        /// <summary>
-        /// Helper class for evaluating a binding from an Item and IBinding instance
-        /// </summary>
-        private class BindingHelper : StyledElement
-        {
-            private BindingExpressionBase? _expression;
-            private IBinding? _lastBinding;
-
-            public BindingHelper(IBinding binding)
-            {
-                UpdateBinding(binding);
-            }
-
-            public static readonly StyledProperty<object> ValueProperty =
-                AvaloniaProperty.Register<BindingHelper, object>("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;
-            }
-        }
     }
 }

+ 79 - 11
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
     {
         /// <summary>
         /// Defines the Text attached property.
-        /// This text will be considered during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>)
+        /// This text will be considered during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>).
+        /// This property is usually applied to an item container directly.
         /// </summary>
         public static readonly AttachedProperty<string?> TextProperty
             = AvaloniaProperty.RegisterAttached<Interactive, string?>("Text", typeof(TextSearch));
 
         /// <summary>
-        /// Sets the <see cref="TextProperty"/> for a control.
+        /// Defines the TextBinding attached property.
+        /// The binding will be applied to each item during text search in <see cref="SelectingItemsControl"/> (such as <see cref="ComboBox"/>).
         /// </summary>
-        /// <param name="control">The control</param>
-        /// <param name="text">The search text to set</param>
+        public static readonly AttachedProperty<IBinding?> TextBindingProperty
+            = AvaloniaProperty.RegisterAttached<Interactive, IBinding?>("TextBinding", typeof(TextSearch));
+
+        // TODO12: Control should be Interactive to match the property definition.
+        /// <summary>
+        /// Sets the value of the <see cref="TextProperty"/> attached property to a given <see cref="Control"/>.
+        /// </summary>
+        /// <param name="control">The control.</param>
+        /// <param name="text">The search text to set.</param>
         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.
         /// <summary>
-        /// Gets the <see cref="TextProperty"/> of a control.
+        /// Gets the value of the <see cref="TextProperty"/> attached property from a given <see cref="Control"/>.
         /// </summary>
-        /// <param name="control">The control</param>
-        /// <returns>The property value</returns>
+        /// <param name="control">The control.</param>
+        /// <returns>The search text.</returns>
         public static string? GetText(Control control)
+            => control.GetValue(TextProperty);
+
+        /// <summary>
+        /// Sets the value of the <see cref="TextBindingProperty"/> attached property to a given <see cref="Interactive"/>.
+        /// </summary>
+        /// <param name="interactive">The interactive element.</param>
+        /// <param name="value">The search text binding to set.</param>
+        public static void SetTextBinding(Interactive interactive, IBinding? value)
+            => interactive.SetValue(TextBindingProperty, value);
+
+        /// <summary>
+        /// Gets the value of the <see cref="TextBindingProperty"/> attached property from a given <see cref="Interactive"/>.
+        /// </summary>
+        /// <param name="interactive">The interactive element.</param>
+        /// <returns>The search text binding.</returns>
+        [AssignBinding]
+        public static IBinding? GetTextBinding(Interactive interactive)
+            => interactive.GetValue(TextBindingProperty);
+
+        /// <summary>
+        /// <para>Gets the effective text of a given item.</para>
+        /// <para>
+        ///   This method uses the first non-empty text from the following list:
+        ///   <list>
+        ///     <item><see cref="TextSearch.TextProperty"/> (if the item is a control)</item>
+        ///     <item><see cref="TextSearch.TextBindingProperty"/></item>
+        ///     <item><see cref="ItemsControl.DisplayMemberBinding"/></item>
+        ///     <item><see cref="IContentControl.Content"/>.<see cref="object.ToString"/> (if the item is a <see cref="IContentControl"/>)</item>
+        ///     <item><see cref="object.ToString"/></item>
+        ///   </list>
+        /// </para>
+        /// </summary>
+        /// <param name="item">The item.</param>
+        /// <param name="textBindingEvaluator">A <see cref="BindingEvaluator{T}"/> used to get the item's text from a binding.</param>
+        /// <returns>The item's text.</returns>
+        internal static string GetEffectiveText(object? item, BindingEvaluator<string?>? 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;
         }
     }
 }

+ 61 - 0
src/Avalonia.Controls/Utils/BindingEvaluator.cs

@@ -0,0 +1,61 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Avalonia.Data;
+
+namespace Avalonia.Controls.Utils;
+
+/// <summary>
+/// Helper class for evaluating a binding from an Item and IBinding instance
+/// </summary>
+internal sealed class BindingEvaluator<T> : 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<T> ValueProperty =
+        AvaloniaProperty.Register<BindingEvaluator<T>, 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<T>? TryCreate(IBinding? binding)
+    {
+        if (binding is null)
+            return null;
+
+        var evaluator = new BindingEvaluator<T>();
+        evaluator.UpdateBinding(binding);
+        return evaluator;
+    }
+}

+ 0 - 5
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

+ 93 - 9
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<ComboBox> configureComboBox,
+            IEnumerable<object> 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);
     }
 }