Browse Source

Implement TextSearch.TextBinding (#18405)

* Implement TextSearch.TextBinding

* Move AssignBinding to TextSearch.GetTextBinding
release/11.3.0-beta1
Julien Lebosquain 10 months ago
committed by GitHub
parent
commit
b09e0d5677
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      samples/ControlCatalog/Pages/ComboBoxPage.xaml
  2. 11
      samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs
  3. 1
      src/Avalonia.Controls/AutoCompleteBox/AutoCompleteBox.cs
  4. 38
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  5. 96
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  6. 90
      src/Avalonia.Controls/Primitives/TextSearch.cs
  7. 61
      src/Avalonia.Controls/Utils/BindingEvaluator.cs
  8. 5
      src/Avalonia.Controls/VirtualizingPanel.cs
  9. 102
      tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

5
samples/ControlCatalog/Pages/ComboBoxPage.xaml

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

11
samples/ControlCatalog/ViewModels/ComboBoxPageViewModel.cs

@ -19,11 +19,11 @@ namespace ControlCatalog.ViewModels
public ObservableCollection<IdAndName> Values { get; set; } = new ObservableCollection<IdAndName> public ObservableCollection<IdAndName> Values { get; set; } = new ObservableCollection<IdAndName>
{ {
new IdAndName(){ Id = "Id 1", Name = "Name 1" }, new IdAndName(){ Id = "Id 1", Name = "Name 1", SearchText = "A" },
new IdAndName(){ Id = "Id 2", Name = "Name 2" }, new IdAndName(){ Id = "Id 2", Name = "Name 2", SearchText = "B" },
new IdAndName(){ Id = "Id 3", Name = "Name 3" }, new IdAndName(){ Id = "Id 3", Name = "Name 3", SearchText = "C" },
new IdAndName(){ Id = "Id 4", Name = "Name 4" }, new IdAndName(){ Id = "Id 4", Name = "Name 4", SearchText = "D" },
new IdAndName(){ Id = "Id 5", Name = "Name 5" }, new IdAndName(){ Id = "Id 5", Name = "Name 5", SearchText = "E" },
}; };
} }
@ -31,5 +31,6 @@ namespace ControlCatalog.ViewModels
{ {
public string? Id { get; set; } public string? Id { get; set; }
public string? Name { get; set; } public string? Name { get; set; }
public string? SearchText { get; set; }
} }
} }

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

38
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@ -197,44 +197,6 @@ namespace Avalonia.Controls.Presenters
return Panel?.Children; 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) internal int IndexFromContainer(Control container)
{ {
if (Panel is VirtualizingPanel v) if (Panel is VirtualizingPanel v)

96
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@ -5,6 +5,7 @@ using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using Avalonia.Controls.Selection; using Avalonia.Controls.Selection;
using Avalonia.Controls.Utils;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
@ -146,7 +147,7 @@ namespace Avalonia.Controls.Primitives
private bool _ignoreContainerSelectionChanged; private bool _ignoreContainerSelectionChanged;
private UpdateState? _updateState; private UpdateState? _updateState;
private bool _hasScrolledToSelectedItem; private bool _hasScrolledToSelectedItem;
private BindingHelper? _bindingHelper; private BindingEvaluator<object?>? _selectedValueBindingEvaluator;
private bool _isSelectionChangeActive; private bool _isSelectionChangeActive;
public SelectingItemsControl() public SelectingItemsControl()
@ -609,10 +610,10 @@ namespace Avalonia.Controls.Primitives
_textSearchTerm += e.Text; _textSearchTerm += e.Text;
var newIndex = Presenter?.GetIndexFromTextSearch(_textSearchTerm); var newIndex = GetIndexFromTextSearch(_textSearchTerm);
if (newIndex >= 0) if (newIndex >= 0)
{ {
SelectedIndex = (int)newIndex; SelectedIndex = newIndex;
} }
StartTextSearchTimer(); StartTextSearchTimer();
@ -678,17 +679,10 @@ namespace Avalonia.Controls.Primitives
{ {
_isSelectionChangeActive = true; _isSelectionChangeActive = true;
if (_bindingHelper is null) var bindingEvaluator = GetSelectedValueBindingEvaluator(value);
{
_bindingHelper = new BindingHelper(value);
}
else
{
_bindingHelper.UpdateBinding(value);
}
// Re-evaluate SelectedValue with the new binding // Re-evaluate SelectedValue with the new binding
SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(selectedItem)); SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(selectedItem));
} }
finally 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 // Matching UWP behavior, if duplicates are present, return the first item matching
// the SelectedValue provided // the SelectedValue provided
foreach (var item in items!) foreach (var item in items!)
{ {
var itemValue = _bindingHelper.Evaluate(item); var itemValue = bindingEvaluator.Evaluate(item);
if (Equals(itemValue, value)) if (Equals(itemValue, value))
{ {
bindingEvaluator.ClearDataContext();
return item; return item;
} }
} }
bindingEvaluator.ClearDataContext();
return AvaloniaProperty.UnsetValue; return AvaloniaProperty.UnsetValue;
} }
@ -1107,12 +1104,12 @@ namespace Avalonia.Controls.Primitives
return; return;
} }
_bindingHelper ??= new BindingHelper(binding); var bindingEvaluator = GetSelectedValueBindingEvaluator(binding);
try try
{ {
_isSelectionChangeActive = true; _isSelectionChangeActive = true;
SetCurrentValue(SelectedValueProperty, _bindingHelper.Evaluate(item)); SetCurrentValue(SelectedValueProperty, bindingEvaluator.Evaluate(item));
} }
finally finally
{ {
@ -1338,6 +1335,37 @@ namespace Avalonia.Controls.Primitives
StopTextSearchTimer(); 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 // 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 // defer changes to the selection model because we have no idea in which order properties
// will be set. Consider: // will be set. Consider:
@ -1367,41 +1395,5 @@ namespace Avalonia.Controls.Primitives
public Optional<object?> SelectedItem { get; set; } public Optional<object?> SelectedItem { get; set; }
public Optional<object?> SelectedValue { 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;
}
}
} }
} }

90
src/Avalonia.Controls/Primitives/TextSearch.cs

@ -1,3 +1,5 @@
using Avalonia.Controls.Utils;
using Avalonia.Data;
using Avalonia.Interactivity; using Avalonia.Interactivity;
namespace Avalonia.Controls.Primitives namespace Avalonia.Controls.Primitives
@ -9,29 +11,95 @@ namespace Avalonia.Controls.Primitives
{ {
/// <summary> /// <summary>
/// Defines the Text attached property. /// 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> /// </summary>
public static readonly AttachedProperty<string?> TextProperty public static readonly AttachedProperty<string?> TextProperty
= AvaloniaProperty.RegisterAttached<Interactive, string?>("Text", typeof(TextSearch)); = AvaloniaProperty.RegisterAttached<Interactive, string?>("Text", typeof(TextSearch));
/// <summary> /// <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> /// </summary>
/// <param name="control">The control</param> public static readonly AttachedProperty<IBinding?> TextBindingProperty
/// <param name="text">The search text to set</param> = 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) 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> /// <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> /// </summary>
/// <param name="control">The control</param> /// <param name="control">The control.</param>
/// <returns>The property value</returns> /// <returns>The search text.</returns>
public static string? GetText(Control control) 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
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;
}
}

5
src/Avalonia.Controls/VirtualizingPanel.cs

@ -193,11 +193,6 @@ namespace Avalonia.Controls
Children.RemoveRange(index, count); Children.RemoveRange(index, count);
} }
internal int GetIndexFromTextSearch(string textSearchTerm)
{
return ItemsPresenter.GetIndexFromTextSearch(Items, textSearchTerm);
}
private protected override void InvalidateMeasureOnChildrenChanged() private protected override void InvalidateMeasureOnChildrenChanged()
{ {
// Don't invalidate measure when children are added or removed: the panel is responsible // Don't invalidate measure when children are added or removed: the panel is responsible

102
tests/Avalonia.Controls.UnitTests/ComboBoxTests.cs

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reactive.Subjects; using System.Reactive.Subjects;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
@ -253,25 +255,105 @@ namespace Avalonia.Controls.UnitTests
int initialSelectedIndex, int initialSelectedIndex,
int expectedSelectedIndex, int expectedSelectedIndex,
string searchTerm, 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)) using (UnitTestApplication.Start(TestServices.StyledWindow))
{ {
var target = new ComboBox var target = new ComboBox
{ {
Template = GetTemplate(), Template = GetTemplate(),
ItemsSource = items.Select(x => new ComboBoxItem { Content = x }), ItemsSource = itemsSource.ToArray(),
}; };
configureComboBox(target);
TestRoot root = new(target) TestRoot root = new(target)
{ {
ClientSize = new(500,500), ClientSize = new(500,500)
}; };
target.ApplyTemplate();
target.Presenter.ApplyTemplate();
target.SelectedIndex = initialSelectedIndex;
root.LayoutManager.ExecuteInitialLayoutPass(); root.LayoutManager.ExecuteInitialLayoutPass();
target.SelectedIndex = initialSelectedIndex;
var args = new TextInputEventArgs var args = new TextInputEventArgs
{ {
@ -284,7 +366,7 @@ namespace Avalonia.Controls.UnitTests
Assert.Equal(expectedSelectedIndex, target.SelectedIndex); Assert.Equal(expectedSelectedIndex, target.SelectedIndex);
} }
} }
[Fact] [Fact]
public void SelectedItem_Validation() public void SelectedItem_Validation()
{ {
@ -551,5 +633,7 @@ namespace Avalonia.Controls.UnitTests
target.SelectedItem = null; target.SelectedItem = null;
Assert.Null(target.SelectionBoxItem); Assert.Null(target.SelectionBoxItem);
} }
private sealed record Item(string Value, string Display);
} }
} }

Loading…
Cancel
Save